From 37db1eb6e043e2a9d2f5bf2305857fb2dfc6a048 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 23 Aug 2026 23:49:06 +0200 Subject: [PATCH 01/24] feat(runtime): run autonomous subscription-backed agents --- MIGRATION_PLAN.md | 117 + README.md | 173 +- package-lock.json | 7 +- package.json | 9 +- pnpm-lock.yaml | 2287 +++++++++++++++++ pnpm-workspace.yaml | 4 + src/pi/cliEngineRun.test.ts | 38 + src/pi/cliEngineRun.ts | 42 + src/pi/cliEngineSpawn.ts | 41 + src/pi/cliEnvironment.test.ts | 19 + src/pi/cliEnvironment.ts | 42 + src/pi/cliProcess.test.ts | 26 + src/pi/cliProcess.ts | 125 + src/pi/cliSession.test.ts | 187 +- src/pi/cliSession.ts | 370 +-- src/pi/cliSessionProcess.test.ts | 89 + src/pi/cliSessionRemoval.test.ts | 24 + src/pi/piAgentHandle.ts | 27 +- src/pi/piAgentWakeSupport.ts | 8 + src/pi/piHarness.ts | 59 +- src/pi/piHarnessEnvironment.test.ts | 65 + src/pi/piHarnessWorldTools.test.ts | 2 +- src/runtime/AGENTS.md | 19 + src/runtime/CLAUDE.md | 1 + src/runtime/agySubscriptionBootstrap.test.ts | 71 + src/runtime/agySubscriptionBootstrap.ts | 98 + src/runtime/agySubscriptionRealm.test.ts | 200 ++ src/runtime/agySubscriptionRealm.ts | 271 ++ src/runtime/cli.test.ts | 153 ++ src/runtime/cli.ts | 216 ++ src/runtime/contractManifest.test.ts | 23 + src/runtime/contractManifest.ts | 60 + src/runtime/engineDispatcher.test.ts | 132 + src/runtime/engineDispatcher.ts | 85 + src/runtime/engineReadiness.test.ts | 101 + src/runtime/engineReadiness.ts | 224 ++ src/runtime/index.ts | 40 + src/runtime/organizationRuntime.test.ts | 196 ++ src/runtime/organizationRuntime.ts | 69 + src/runtime/organizationRuntime.types.test.ts | 79 + src/runtime/organizationRuntimeControl.ts | 133 + src/runtime/organizationRuntimeHost.test.ts | 294 +++ src/runtime/organizationRuntimeHost.ts | 411 +++ src/runtime/organizationRuntimeParsing.ts | 120 + src/runtime/physicalReadiness.test.ts | 48 + src/runtime/physicalReadiness.ts | 135 + src/runtime/storeCoordination.ts | 73 + src/runtime/wakeAcceptance.test.ts | 210 ++ .../wakeAcceptanceReconciliation.test.ts | 198 ++ src/runtime/wakeAcceptanceReconciliation.ts | 139 + src/runtime/wakeAcceptanceStore.ts | 372 +++ src/runtime/wakeAcceptanceTypes.ts | 113 + 52 files changed, 7862 insertions(+), 183 deletions(-) create mode 100644 MIGRATION_PLAN.md create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/pi/cliEngineRun.test.ts create mode 100644 src/pi/cliEngineRun.ts create mode 100644 src/pi/cliEngineSpawn.ts create mode 100644 src/pi/cliEnvironment.test.ts create mode 100644 src/pi/cliEnvironment.ts create mode 100644 src/pi/cliProcess.test.ts create mode 100644 src/pi/cliProcess.ts create mode 100644 src/pi/cliSessionProcess.test.ts create mode 100644 src/pi/cliSessionRemoval.test.ts create mode 100644 src/pi/piHarnessEnvironment.test.ts create mode 100644 src/runtime/AGENTS.md create mode 120000 src/runtime/CLAUDE.md create mode 100644 src/runtime/agySubscriptionBootstrap.test.ts create mode 100644 src/runtime/agySubscriptionBootstrap.ts create mode 100644 src/runtime/agySubscriptionRealm.test.ts create mode 100644 src/runtime/agySubscriptionRealm.ts create mode 100644 src/runtime/cli.test.ts create mode 100644 src/runtime/cli.ts create mode 100644 src/runtime/contractManifest.test.ts create mode 100644 src/runtime/contractManifest.ts create mode 100644 src/runtime/engineDispatcher.test.ts create mode 100644 src/runtime/engineDispatcher.ts create mode 100644 src/runtime/engineReadiness.test.ts create mode 100644 src/runtime/engineReadiness.ts create mode 100644 src/runtime/index.ts create mode 100644 src/runtime/organizationRuntime.test.ts create mode 100644 src/runtime/organizationRuntime.ts create mode 100644 src/runtime/organizationRuntime.types.test.ts create mode 100644 src/runtime/organizationRuntimeControl.ts create mode 100644 src/runtime/organizationRuntimeHost.test.ts create mode 100644 src/runtime/organizationRuntimeHost.ts create mode 100644 src/runtime/organizationRuntimeParsing.ts create mode 100644 src/runtime/physicalReadiness.test.ts create mode 100644 src/runtime/physicalReadiness.ts create mode 100644 src/runtime/storeCoordination.ts create mode 100644 src/runtime/wakeAcceptance.test.ts create mode 100644 src/runtime/wakeAcceptanceReconciliation.test.ts create mode 100644 src/runtime/wakeAcceptanceReconciliation.ts create mode 100644 src/runtime/wakeAcceptanceStore.ts create mode 100644 src/runtime/wakeAcceptanceTypes.ts diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md new file mode 100644 index 0000000..d4bbd49 --- /dev/null +++ b/MIGRATION_PLAN.md @@ -0,0 +1,117 @@ +# Daimon organization-runtime migration plan + +## Phase 1A: public contract — complete + +- Add only the versioned, organization-neutral + `noopolis.daimon.organization-runtime.v1` parser, schema, public types, and + `@noopolis/daimon/runtime` export. +- The v1 config is a strict flat projection: host control binding plus an + unordered, non-empty collection of isolated agent records. It cannot carry + organization graph, coordination, scheduling, deployment, or Moltnet data. + The standard JSON Schema covers structure; the pure public parser is + normative for semantic checks such as duplicate agent ids. +- Define the host, wake, activity, health, and shutdown interfaces now. Phase + 1A is validation-only; Phase 1B provides the host, process, server, engine + dispatch, and CLI implementation while retaining the same public contract. + +## Phase 1B: isolated runtime host — complete + +- Add the organization-neutral `noopolis.daimon.organization-runtime.v1` + host implementation behind the already published public API. +- A host reads one config, validates it, creates one isolated existing Daimon + harness per agent record, routes authenticated wake, health, and activity + operations by agent id, serializes each agent's wakes independently, and + quiesces all children on shutdown. +- The schema is a strict flat projection. It enumerates only: schema version, + agent id, runtime-home path, workspace path, engine intent, control endpoint, + and surface bindings. It rejects every unknown field and specifically rejects + team, parent, member, role, edge, schedule, wake-selection, deployment, and + Moltnet-topology semantics. The host does no wake generation or coordination; + it only provides agent-id routing, per-agent queue/lifecycle/health/activity. +- Engine process creation remains in Daimon. The public runtime does not accept + arbitrary commands, raw process handles, or caller-supplied environment maps. + Fake engines are test-only injection points and make this phase deterministic. + +The exact v1 JSON shape is: + +```ts +type OrganizationRuntimeEngineIntent = + | { kind: "codex" | "grok" | "agy" }; +type OrganizationRuntimeAgentConfig = Readonly<{ + id: string; + name: string; + instructions: string; + runtimeHomePath: string; + workspacePath: string; + engine: OrganizationRuntimeEngineIntent; +}>; +type OrganizationRuntimeConfig = Readonly<{ + version: "noopolis.daimon.organization-runtime.v1"; + host: Readonly<{ + bindHost: string; + port: number; + controlTokenEnv: string; + }>; + agents: readonly OrganizationRuntimeAgentConfig[]; +}>; +``` + +All objects are strict; there are no optional properties or arbitrary metadata +bags in v1. `id` is unique, root agent paths are absolute, caller-created real +directories, and physically isolated by held directory identities. Runtime +homes must be owned `0700`; workspaces must be owned and not group/other +writable. The +control token is identified only by a safe environment-variable name; secret +material never appears in the config. Unknown schema versions are rejected +before a host starts or a child is constructed. The public signatures are: + +```ts +parseOrganizationRuntimeConfig(value: unknown): OrganizationRuntimeConfig; + +interface OrganizationRuntimeHost { + start(): Promise; + wake(request: OrganizationRuntimeWakeRequest): Promise; + health(agentId?: string): Promise; + activity(request: OrganizationRuntimeActivityRequest): Promise; + stop(): Promise; +} +``` + +`OrganizationRuntimeHost` exposes `wake`, `health`, `activity`, and idempotent +`stop`; each request is keyed by an agent id and the host exposes no +coordination/wake-generation API. Fake engine construction is internal +test-only injection, never config data or a public production escape hatch. +The CLI is exactly `daimon-runtime run --config `. + +## Files + +- Add `src/runtime/` with its own `AGENTS.md` and `CLAUDE.md` symlink. It now + contains the strict contract, isolated host, control server, CLI, and focused + tests, with each source file kept below 400 lines. +- Additive pre-1.0 bump to `0.2.0`; retain the existing `.`, `./pi`, and + `./observability` entrypoints unchanged, then add `./runtime`. +- Update docs only as required to expose this exact contract and retain safe + compatible per-agent APIs. + +## Tests and verification + +- Contract coverage: invalid config; forbidden organization fields; safe auth + environment names; agent-home/workspace path syntax and physical roots; + duplicate ids; closed Codex/Grok/AGY engine intents; public method types; + retained imports; package closure. +- Run typecheck, focused tests, full unit suite, build, `npm pack`, install the + packed tarball into a clean temporary consumer, import the retained and new + entrypoints there, and inspect the final diff for forbidden consumer + terminology and raw credential/process APIs. + +## Migration and terminal conditions + +- This completed phase introduces the Daimon host and public contract only; it does not + change any downstream consumer. A deferred downstream compiler/consumer E2E + phase will emit one compiled v1 config, start one container entrypoint/host + process, verify N isolated one-agent runtimes with targeted and concurrent + fake wakes, and prove that the consumer never instantiates Daimon handles or + engines. No Docker work is permitted in this Daimon phase. +- Stop and return for review when implementation, test evidence, package + closure, and an explicit public-contract summary are complete. Do not touch + credentials, Docker, live repositories, or external services. diff --git a/README.md b/README.md index a450193..45430c8 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ independent wake and claim—they are never Daimon wake inputs. npm install @noopolis/daimon ``` -The latest published version is 0.1.1; this README describes the source tree (0.1.2). +The latest published version may differ; this README describes the source tree (0.2.0). For Pi agents with memory enabled, install Mneme too: @@ -49,6 +49,177 @@ Pi-specific exports live under the Pi subpath: import { PiHarnessAdapter } from "@noopolis/daimon/pi"; ``` +## Organization-runtime contract + +`@noopolis/daimon/runtime` exports a standard JSON Schema for structural +validation plus the strict, side-effect-free semantic +`validateOrganizationRuntimeConfig` / `parseOrganizationRuntimeConfig` API and +a narrow multi-agent Daimon host: + +```ts +import { parseOrganizationRuntimeConfig } from "@noopolis/daimon/runtime"; + +const config = parseOrganizationRuntimeConfig({ + version: "noopolis.daimon.organization-runtime.v1", + host: { + bindHost: "127.0.0.1", + port: 4318, + controlTokenEnv: "DAIMON_CONTROL_TOKEN" + }, + agents: [{ + id: "writer", + name: "Writer", + instructions: "Write the report.", + workspacePath: "/runtime/workspaces/writer", + runtimeHomePath: "/runtime/homes/writer", + engine: { kind: "codex" } + }] +}); +``` + +It intentionally contains no teams, roles, parent/member links, schedules, +wake policies, deployment settings, Moltnet data, commands, argument arrays, +environment maps, or credentials. `controlTokenEnv` is only the safe name of a +variable; the token value is never serialized. The only organization-host +engine intents are `codex`, `grok`, and `agy`; Pi remains available through the +separate one-agent `@noopolis/daimon/pi` API. + +`createOrganizationRuntimeHost(config)` creates an organization runtime host. +Call `await host.start()` to construct one isolated Daimon harness per agent, +after reading the non-blank control token named by `controlTokenEnv`. +It routes only authenticated, targeted wakes; each agent's wakes are serial, +while separate agents can run concurrently. It has no organization or +coordination API. Run the HTTP control process with: + +```bash +daimon-runtime run --config /runtime/daimon-runtime.json +``` + +The process exposes authenticated `POST /v1/wake`, `GET /v1/health`, and +`GET /v1/activity` endpoints. Send the token only as `Authorization: Bearer +`; it never appears in configuration or activity output. + +For durable control-plane delivery, provide a caller-created, current-user +owned `0700` directory in `DAIMON_RUNTIME_ACCEPTANCE_STORE`. This enables the +additive authenticated v2 API: `POST /v2/wakes` persistently accepts one +bounded, addressed wake under its `(agent_id, delivery_id, request digest)` +identity before the turn starts, and `GET /v2/wake-receipts/` +returns only its strict redacted lifecycle status. The store is a private +runtime authority, not a credential directory; it must be mounted only into +the Daimon host. Equal retries return the original acceptance, while a changed +payload for that delivery id is rejected. There is no v2 list/activity endpoint +and no model output in acceptance or receipt responses. If the variable is not +provided, the v1 endpoints remain available and v2 is not exposed. + +Each accepted delivery also has a private, bounded execution claim with an +owner and generation fence. A second host sharing the authority may observe a +receipt but cannot dispatch while the live claimant owns it; only the matching, +unexpired fence can record its terminal state. Claim revalidation and receipt +replacement are covered by a separate exclusive, fsynced per-receipt transition +lock; a recovered generation fences an older paused writer before replacement. +After a host crash, a bounded stale claim can be recovered and replayed. This is durable at-least-once turn +delivery, not exactly-once cognition: if an engine has an external effect and +the host crashes before its terminal receipt is persisted, recovery can run the +turn again. Callers requiring exactly-once external effects must fence or +deduplicate those effects at their own destination. + +The durable v2 store is a Linux-container contract. Its non-expiring commit +lock records the owner PID, Linux `/proc` process-start identity, boot id, and +PID-namespace device/inode identity; recovery is allowed only after that exact +owner is proven dead in the same namespace, so PID reuse cannot unlock a live +writer. A different or unobservable PID namespace raises the typed, +path-free `WakeTransitionLockBlockedError` with code +`offline_reconciliation_required`; it is never automatically cleared. For a +replacement container, deployment authority must first prove the prior +container absent or quiescent, then perform an explicit offline stale-lock +reconciliation. On a platform where this identity cannot be proven, v2 fails +closed rather than using a clock-based lock; v1 remains available without the +v2 store. + +### Offline transition reconciliation + +`reconcileOfflineWakeTransition` is a deployment-admin library operation, not +an HTTP endpoint and never a normal host action. It accepts only the versioned +`noopolis.daimon.offline-transition-reconciliation.v1` request, including the +exact store and lock device/inode identities, owner/generation/PID-namespace +identity, and an opaque absent-or-quiescent deployment attestation. The caller +must mount the store exclusively offline and supply the deployment authority's +attestation verifier. Daimon gives that verifier a canonical immutable +authorization context (deployment identity, nonce, request digest, every +store/delivery/lock identity, and every stale host registration's exact file +device/inode, owner, process, boot, and PID-namespace identity); its returned +proof receipt must echo that exact digest and nonce and certify +`exclusive_store: true`. A same-namespace registration is cleared only when +its owner is proven dead. A cross-namespace registration is cleared only when +the proof authorizes that exact canonical registration digest after deployment +has proven the old container absent or quiescent. Unknown, live, unlisted, or +changed registrations block. Daimon validates the physical store, delivery +record, and lock again immediately before its fsynced removal. The proof +itself is never persisted or returned; the durable redacted receipt records +only cleared registration digests. + +The operation writes a durable redacted receipt and returns it on an identical +replay. An identity mismatch, invalid proof, concurrent administrative lease, +or changed replay returns the stable blocked result +`{ state: "blocked", code: "offline_reconciliation_required" }`. It performs +no runtime auto-clear and has no CLI or public-server admin route in C0. The +admin lease itself carries PID/start/boot/PID-namespace identity: a retry may +recover it only after proving the prior owner dead in the same namespace. Lease +unlink and directory fsync failures prevent a successful result; the durable +prepared receipt lets an authorized offline retry finish after a crash. + +Every running wake-acceptance store also holds its own fsynced host-lifetime +registration with the same process identity. A host writes that registration +before becoming usable and rechecks the offline lease; the offline operation +takes its lease first, then rejects live or unprovable host registrations. +This permits normal concurrent hosts, but prevents either startup/admin race +from overlapping an administrative reconciliation. + +The runtime may bind `0.0.0.0` when its caller places it on a dedicated private +container network. Daimon neither publishes ports nor selects networks; the +deployer must keep the control endpoint un-published and allow only its +authorized control-plane client to reach it. + +The JSON Schema is deliberately standard and structural. The pure parser and +validator are normative for semantic checks that standard JSON Schema cannot +express, including unique ids, own-property records, and pairwise +non-overlapping canonical POSIX workspace/runtime-home paths; call the parser +before runtime side effects. The parser canonicalizes absolute path spellings +before retaining or comparing them. Before creating agents, the host verifies +that caller-created roots are real, current-user-owned directories without +symlink components; runtime homes must be exactly `0700`, and workspaces must +not be group/other writable. Daimon never creates or removes these roots. + +For a production CLI engine, Daimon resolves the engine from `PATH` once, +pins its canonical executable identity, probes only `--version`, and rechecks +that identity before every child process. Codex and Grok use caller-provisioned +private `.codex/auth.json` and `.grok/auth.json` refresh-token files beneath +each agent's `runtimeHomePath`. Their children receive only the matching +engine home, runtime/XDG paths, locale/timezone, and a PATH sufficient for the +already pinned executable. They never inherit arbitrary host variables. +Missing, replaced, malformed, or unsafe engine authority prevents startup or +fails the affected wake without publishing credential contents or file paths. + +AGY subscription authentication is different: it uses OS-native secure +storage. Daimon supervises one private D-Bus plus Secret Service realm for all +AGY agents on the host, holds an exclusive lease on its durable encrypted +keyring volume, and unlocks it from an independent caller-owned `0600` +read-only mount through bounded stdin. Only AGY children receive that realm's +exact bus address. Normal startup proves enrollment with `agy models` before +any agent starts. + +Enroll an empty realm once from an interactive terminal: + +```bash +daimon-runtime auth agy login --config /runtime/daimon-runtime.json +``` + +The command starts the same realm and launches the pinned `agy` as a Daimon +child with no AGY arguments, allowing its remote URL-and-code sign-in flow. +After the operator exits AGY, Daimon runs the noninteractive enrollment proof +and preserves the durable keyring state. It never accepts a portable AGY token +file, API key, ADC credential, token export, or ambient D-Bus session. + By default, the in-process Mneme runtime uses the same path as each agent's `runtimeHomePath`. If you need agents to keep separate Pi/runtime directories but share one memory bank, pass an explicit `memory.runtimeHomePath` in diff --git a/package-lock.json b/package-lock.json index d151fa9..d046ccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@noopolis/daimon", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@noopolis/daimon", - "version": "0.1.2", + "version": "0.2.0", "license": "MIT", "dependencies": { "@earendil-works/pi-ai": "^0.79.10", @@ -15,6 +15,9 @@ "@noopolis/mneme": "^0.1.1", "ajv": "^8.17.1" }, + "bin": { + "daimon-runtime": "dist/runtime/cli.js" + }, "devDependencies": { "@types/node": "^24.12.4", "tsx": "^4.21.0", diff --git a/package.json b/package.json index 36f3f01..282e6e4 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,14 @@ { "name": "@noopolis/daimon", - "version": "0.1.2", + "version": "0.2.0", "description": "Noopolis native per-agent runtime harness built on Pi.", "license": "MIT", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "daimon-runtime": "dist/runtime/cli.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -18,6 +21,10 @@ "./observability": { "types": "./dist/observability/index.d.ts", "import": "./dist/observability/index.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "import": "./dist/runtime/index.js" } }, "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f9fe011 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2287 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@earendil-works/pi-ai': + specifier: ^0.79.10 + version: 0.79.10(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) + '@earendil-works/pi-coding-agent': + specifier: ^0.79.10 + version: 0.79.10(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.30.0(zod@4.4.3) + '@noopolis/mneme': + specifier: ^0.1.1 + version: 0.1.1 + ajv: + specifier: ^8.17.1 + version: 8.20.0 + devDependencies: + '@types/node': + specifier: ^24.12.4 + version: 24.13.3 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.8': + resolution: {integrity: sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.69': + resolution: {integrity: sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.71': + resolution: {integrity: sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.14': + resolution: {integrity: sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.76': + resolution: {integrity: sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.80': + resolution: {integrity: sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.69': + resolution: {integrity: sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.13': + resolution: {integrity: sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.75': + resolution: {integrity: sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.33': + resolution: {integrity: sha512-1Dd5WyEE2Kb3HvY44u7Ob16ST2W6iutOqsQ8Y2hUmsL2mAH/STlGS1dS9h3IOE6L7Ld3AR2HzKJ6XeCMOw8Peg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.28': + resolution: {integrity: sha512-Z1EDXnS01P7H5jVrUx+/dBqV0m7dta7bSxLclkOuDuS93pNNQm0IcT4YLUbuvWKPYNxbI8aTG0p5Br30GSKDgA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.51': + resolution: {integrity: sha512-jdgP3jR5Q96j1jjZ98GGwpGg1CBNFIO2YE+vXg8cg8PvNY4NvgQNYJsqDaRX2PYv5gSUX/+C0D58Fhspj9ELMQ==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.43': + resolution: {integrity: sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.45': + resolution: {integrity: sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1111.0': + resolution: {integrity: sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.4': + resolution: {integrity: sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.10': + resolution: {integrity: sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.39': + resolution: {integrity: sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@earendil-works/pi-agent-core@0.79.10': + resolution: {integrity: sha512-XKxgdjhcPuyjrthCOFSgfzT3xZ1uBrJ1IMVDxci1to6hIN6BIg9J5iY8q0pGXK1DLgATLP23da+1UyZLwA360Q==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.79.10': + resolution: {integrity: sha512-9jR23tOl0BIUdQMn70Gr72xYBpM7Xgl9Lyv7gAnU1USfkNRuYG/f/edLl+n/Dp/RafDW3JI4DF7y/GhgkORuew==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-coding-agent@0.79.10': + resolution: {integrity: sha512-YxaRhmgyDTvLDdGVbe7YzTHV80oL5mX5odg6EhGHz3w5Wu1Ix8DCw7bhtiOBLGQNFRcknia0zPmVWIj30XP1EA==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-tui@0.79.10': + resolution: {integrity: sha512-FUVOjDn1DVwM1uHD5MNYboXQrXjIDbSt+BQ3py7nQWCY62tKfxgiM1OBMxTcwRWLfSdZHUPpV0hm1loIdUJnPw==} + engines: {node: '>=22.19.0'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@mariozechner/clipboard-darwin-arm64@0.3.9': + resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@mariozechner/clipboard-darwin-universal@0.3.9': + resolution: {integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==} + engines: {node: '>= 10'} + os: [darwin] + + '@mariozechner/clipboard-darwin-x64@0.3.9': + resolution: {integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + resolution: {integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@mariozechner/clipboard@0.3.9': + resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} + engines: {node: '>= 10'} + + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@noopolis/mneme@0.1.1': + resolution: {integrity: sha512-610VXxML7Sv2qxja57Nx3/ou29/dhkZCz/NaxRhe5tEEJoEE/fe8L9As0Qxm8K5Thv4oC3KK6U4NcbVyR+5/FQ==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + + '@smithy/core@3.33.2': + resolution: {integrity: sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.7.2': + resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.11.2': + resolution: {integrity: sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.2': + resolution: {integrity: sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.6.2: + resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + hono@4.13.2: + resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==} + engines: {node: '>=16.9.0'} + + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typebox@1.1.38: + resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@8.5.0: + resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} + engines: {node: '>=22.19.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.974.4 + '@aws-sdk/util-locate-window': 3.965.10 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.974.4 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.974.4 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-node': 3.972.80 + '@aws-sdk/eventstream-handler-node': 3.972.33 + '@aws-sdk/middleware-eventstream': 3.972.28 + '@aws-sdk/middleware-websocket': 3.972.51 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.8': + dependencies: + '@aws-sdk/types': 3.974.4 + '@aws-sdk/xml-builder': 3.972.39 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.2 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.71': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.14': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-login': 3.972.76 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.76': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.80': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-ini': 3.973.14 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.13': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/token-providers': 3.1111.0 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.75': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.33': + dependencies: + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.28': + dependencies: + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.51': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.43': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.45': + dependencies: + '@aws-sdk/types': 3.974.4 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1111.0': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.4': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.10': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.39': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + + '@babel/runtime@7.29.7': {} + + '@earendil-works/pi-agent-core@0.79.10(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-ai': 0.79.10(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.79.10(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.21.3)(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.79.10(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-agent-core': 0.79.10(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) + '@earendil-works/pi-ai': 0.79.10(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) + '@earendil-works/pi-tui': 0.79.10 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.1.38 + undici: 8.5.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-tui@0.79.10': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.6.5 + ws: 8.21.3 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@hono/node-server@2.1.1(hono@4.13.2)': + dependencies: + hono: 4.13.2 + + '@mariozechner/clipboard-darwin-arm64@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-universal@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-x64@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard@0.3.9': + optionalDependencies: + '@mariozechner/clipboard-darwin-arm64': 0.3.9 + '@mariozechner/clipboard-darwin-universal': 0.3.9 + '@mariozechner/clipboard-darwin-x64': 0.3.9 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.9 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-musl': 0.3.9 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 + optional: true + + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/semantic-conventions': 1.43.0 + ws: 8.21.3 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.2) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.6.2(express@5.2.1) + hono: 4.13.2 + jose: 6.2.9 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@noopolis/mneme@0.1.1': + dependencies: + '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + + '@silvia-odwyer/photon-node@0.3.4': {} + + '@smithy/core@3.33.2': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.7.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.11.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.2': + dependencies: + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/types@4.17.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/retry@0.12.0': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bowser@2.14.1: {} + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + buffer-equal-constant-time@1.0.1: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chalk@5.6.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + diff@8.0.4: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + express-rate-limit@8.6.2(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.5: {} + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gaxios@7.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.1 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + google-auth-library@10.9.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.1 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + highlight.js@10.7.3: {} + + hono@4.13.2: {} + + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@7.0.5: {} + + inherits@2.0.4: {} + + ip-address@10.5.0: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + jose@6.2.9: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + long@5.3.2: {} + + lru-cache@11.5.2: {} + + marked@18.0.5: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.9 + + minipass@7.1.3: {} + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openai@6.26.0(ws@8.21.3)(zod@4.4.3): + optionalDependencies: + ws: 8.21.3 + zod: 4.4.3 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + parseurl@1.3.3: {} + + partial-json@0.1.7: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-to-regexp@8.4.2: {} + + pkce-challenge@5.0.1: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + protobufjs@7.6.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 24.13.3 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + retry@0.12.0: {} + + retry@0.13.1: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + semver@7.8.0: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + ts-algebra@2.0.0: {} + + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typebox@1.1.38: {} + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + undici@8.5.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + web-streams-polyfill@3.3.3: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + ws@8.21.3: {} + + yaml@2.9.0: {} + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..8d2ca2c --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + '@google/genai': set this to true or false + esbuild: set this to true or false + protobufjs: set this to true or false diff --git a/src/pi/cliEngineRun.test.ts b/src/pi/cliEngineRun.test.ts new file mode 100644 index 0000000..7ba0944 --- /dev/null +++ b/src/pi/cliEngineRun.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { runEngine, runEngineDetailed } from "./cliEngineRun.js"; + +test("one-shot CLI helpers pass the assigned runtime home to every child", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-engine-home-")); + const priorPath = process.env.PATH; + const home = path.join(root, "isolated-home"); + const command = path.join(root, "agy"); + await writeFile(command, [ + "#!/usr/bin/env node", + "process.stdout.write(JSON.stringify({ home: process.env.HOME, config: process.env.XDG_CONFIG_HOME, data: process.env.XDG_DATA_HOME, state: process.env.XDG_STATE_HOME, cache: process.env.XDG_CACHE_HOME, tmp: process.env.TMPDIR }));" + ].join("\n")); + await chmod(command, 0o700); + try { + process.env.PATH = `${root}${path.delimiter}${priorPath ?? ""}`; + const paths = { workspacePath: root, runtimeHomePath: home }; + const result = await runEngineDetailed("agy", "probe", paths); + assert.deepEqual(JSON.parse(result.text), { + home, + config: `${home}/.config`, + data: `${home}/.local/share`, + state: `${home}/.local/state`, + cache: `${home}/.cache`, + tmp: `${home}/.tmp` + }); + await Promise.all([`${home}/.cache`, `${home}/.tmp`].map(async (directory) => assert.equal((await stat(directory)).isDirectory(), true))); + assert.equal(await runEngine("agy", "probe", paths), result.text); + } finally { + if (priorPath === undefined) delete process.env.PATH; + else process.env.PATH = priorPath; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/pi/cliEngineRun.ts b/src/pi/cliEngineRun.ts new file mode 100644 index 0000000..17a2be0 --- /dev/null +++ b/src/pi/cliEngineRun.ts @@ -0,0 +1,42 @@ +import { createCliSessionFactory, type CliEngineKind, type CliEngineOptions } from "./cliSession.js"; + +export interface EngineRunResult { + readonly durationMs: number; + readonly outputChars: number; + readonly promptChars: number; + readonly text: string; +} + +export const runEngineDetailed = async ( + engine: CliEngineKind, + prompt: string, + paths: { readonly workspacePath: string; readonly runtimeHomePath?: string } +): Promise => { + const startedAt = Date.now(); + const options: CliEngineOptions = engine === "agy" + ? { engine, maxToolTurns: 1, timeoutMs: 180_000, toolAccess: "none" } + : { engine, maxToolTurns: 2, timeoutMs: 180_000 }; + const { session } = await createCliSessionFactory(options)({ + cwd: paths.workspacePath, + runtimeHomePath: paths.runtimeHomePath + }); + let text = ""; + const unsubscribe = session.subscribe((event) => { + if (event.type !== "turn_end") return; + if (!("content" in event.message)) return; + text = Array.isArray(event.message.content) + ? event.message.content.filter((entry) => entry.type === "text").map((entry) => entry.text).join("") + : event.message.content; + }); + await session.prompt(prompt); + unsubscribe(); + session.dispose(); + await session.disposeAsync?.(); + return { durationMs: Date.now() - startedAt, outputChars: text.length, promptChars: prompt.length, text }; +}; + +export const runEngine = async ( + engine: CliEngineKind, + prompt: string, + paths: { readonly workspacePath: string; readonly runtimeHomePath?: string } +): Promise => (await runEngineDetailed(engine, prompt, paths)).text; diff --git a/src/pi/cliEngineSpawn.ts b/src/pi/cliEngineSpawn.ts new file mode 100644 index 0000000..048e8b4 --- /dev/null +++ b/src/pi/cliEngineSpawn.ts @@ -0,0 +1,41 @@ +import { spawn, type ChildProcess } from "node:child_process"; + +import { trackCliChild } from "./cliProcess.js"; +import { cliChildEnvironment } from "./cliEnvironment.js"; +import type { CliEngineOptions, CliSessionInput } from "./cliSession.js"; + +export const renderCodexArgs = ( + options: Pick, + cwd: string, + endpoint: string | undefined, + sandbox: string = process.env.DAIMON_CODEX_SANDBOX ?? "danger-full-access" +): string[] => [...(options.commandArgs ?? []), "exec", "--sandbox", sandbox, "--skip-git-repo-check", "--color", "never", "-C", cwd, + "-c", `mcp_servers.daimon.url=${endpoint}`, "-"]; + +export const spawnEngine = ( + options: CliEngineOptions, + prompt: string, + input: CliSessionInput, + endpoint: string | undefined +): ChildProcess => { + const command = options.command ?? options.engine; + const env = cliChildEnvironment([ + ...(options.redactedEnvironmentNames ?? []), + ...(input.daimonSecretEnvironmentNames ?? []) + ], input.runtimeHomePath, { + dbusSessionBusAddress: options.engine === "agy" ? options.dbusSessionBusAddress : undefined, + engine: options.engine, + executablePath: options.command, + engineHomePath: options.engineHomePath + }); + if (options.engine === "codex") { + const child = trackCliChild(spawn(command, renderCodexArgs(options, input.cwd, endpoint), { cwd: input.cwd, env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] })); + child.stdin!.on("error", () => undefined); + child.stdin!.write(prompt); child.stdin!.end(); + return child; + } + const args = options.engine === "grok" + ? [...(options.commandArgs ?? []), "--single", prompt, "--max-turns", String(options.maxToolTurns), "--no-memory", "--disable-web-search", "--cwd", input.cwd, "--output-format", "plain"] + : [...(options.commandArgs ?? []), "--print", prompt, "--print-timeout", `${options.timeoutMs}ms`]; + return trackCliChild(spawn(command, args, { cwd: input.cwd, env, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] })); +}; diff --git a/src/pi/cliEnvironment.test.ts b/src/pi/cliEnvironment.test.ts new file mode 100644 index 0000000..0746439 --- /dev/null +++ b/src/pi/cliEnvironment.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { cliChildEnvironment } from "./cliEnvironment.js"; + +test("passes only an explicit local Linux Secret Service bus to AGY", () => { + const previous = process.env.DBUS_SESSION_BUS_ADDRESS; + try { + process.env.DBUS_SESSION_BUS_ADDRESS = "unix:path=/ambient/bus"; + const exact = "unix:path=/daimon/private/bus"; + assert.equal(cliChildEnvironment([], "/runtime", { engine: "agy", dbusSessionBusAddress: exact }).DBUS_SESSION_BUS_ADDRESS, exact); + assert.equal(cliChildEnvironment([], "/runtime", { engine: "agy" }).DBUS_SESSION_BUS_ADDRESS, undefined); + assert.equal(cliChildEnvironment([], "/runtime", { engine: "codex", dbusSessionBusAddress: exact }).DBUS_SESSION_BUS_ADDRESS, undefined); + assert.equal(cliChildEnvironment([], "/runtime", { engine: "agy", dbusSessionBusAddress: "tcp:host=credential-canary" }).DBUS_SESSION_BUS_ADDRESS, undefined); + } finally { + if (previous === undefined) delete process.env.DBUS_SESSION_BUS_ADDRESS; + else process.env.DBUS_SESSION_BUS_ADDRESS = previous; + } +}); diff --git a/src/pi/cliEnvironment.ts b/src/pi/cliEnvironment.ts new file mode 100644 index 0000000..b6891cf --- /dev/null +++ b/src/pi/cliEnvironment.ts @@ -0,0 +1,42 @@ +import path from "node:path"; + +type CliEnvironmentIntent = Readonly<{ + dbusSessionBusAddress?: string; + engine?: "codex" | "grok" | "agy"; + executablePath?: string; + engineHomePath?: string; +}>; + +/** Build a positive child environment; agent CLIs never inherit host secrets. */ +export const cliChildEnvironment = ( + _redactedNames: readonly string[], runtimeHomePath?: string, intent: CliEnvironmentIntent = {} +): NodeJS.ProcessEnv => { + if (runtimeHomePath === undefined) { + // This legacy branch is only used by the standalone Pi helpers, which do + // not claim production organization-runtime isolation. + return { PATH: process.env.PATH, LANG: process.env.LANG ?? "C", LC_ALL: process.env.LC_ALL ?? "C", TZ: process.env.TZ ?? "UTC" }; + } + const engine = intent.engine; + const engineVariable = engine === "codex" ? "CODEX_HOME" : engine === "grok" ? "GROK_HOME" : engine === "agy" ? "ANTIGRAVITY_CLI_HOME" : undefined; + const engineHome = intent.engineHomePath; + const keyringBus = engine === "agy" ? localSecretServiceBus(intent.dbusSessionBusAddress) : undefined; + return { + // The executable is pinned absolutely. PATH remains only for a trusted + // interpreter in a local shebang (and never selects the engine itself). + PATH: intent.executablePath === undefined ? process.env.PATH : `${path.dirname(intent.executablePath)}${path.delimiter}${path.dirname(process.execPath)}`, + LANG: process.env.LANG ?? "C", + LC_ALL: process.env.LC_ALL ?? "C", + TZ: process.env.TZ ?? "UTC", + HOME: runtimeHomePath, + XDG_CONFIG_HOME: `${runtimeHomePath}/.config`, + XDG_DATA_HOME: `${runtimeHomePath}/.local/share`, + XDG_STATE_HOME: `${runtimeHomePath}/.local/state`, + XDG_CACHE_HOME: `${runtimeHomePath}/.cache`, + TMPDIR: `${runtimeHomePath}/.tmp`, + ...(engineVariable === undefined || engineHome === undefined ? {} : { [engineVariable]: engineHome }), + ...(keyringBus === undefined ? {} : { DBUS_SESSION_BUS_ADDRESS: keyringBus }) + }; +}; + +const localSecretServiceBus = (value: string | undefined): string | undefined => + value !== undefined && /^unix:(?:path|abstract)=[^;,\r\n]+$/u.test(value) ? value : undefined; diff --git a/src/pi/cliProcess.test.ts b/src/pi/cliProcess.test.ts new file mode 100644 index 0000000..3598a23 --- /dev/null +++ b/src/pi/cliProcess.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; + +import { CliProcessCleanupError, terminateChild } from "./cliProcess.js"; + +test("process-group permission failures are typed cleanup errors", async (context) => { + if (process.platform === "win32") { + context.skip("detached process groups are not available on Windows"); + return; + } + const child = Object.assign(new EventEmitter(), { exitCode: null, pid: 41_337, signalCode: null }) as never; + const originalKill = process.kill; + Object.defineProperty(process, "kill", { + configurable: true, + value: (() => { + const error = Object.assign(new Error("permission denied"), { code: "EPERM" }); + throw error; + }) as typeof process.kill + }); + try { + await assert.rejects(terminateChild(child), CliProcessCleanupError); + } finally { + Object.defineProperty(process, "kill", { configurable: true, value: originalKill }); + } +}); diff --git a/src/pi/cliProcess.ts b/src/pi/cliProcess.ts new file mode 100644 index 0000000..b948423 --- /dev/null +++ b/src/pi/cliProcess.ts @@ -0,0 +1,125 @@ +import type { ChildProcess } from "node:child_process"; +import { readdir, readFile } from "node:fs/promises"; + +type ChildState = { closed: boolean; close: Promise; pgid?: number }; + +const childStates = new WeakMap(); +const delay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +export class CliProcessCleanupError extends Error { + public constructor(operation: string, cause?: unknown) { + super(`CLI process cleanup failed while ${operation}`, { cause }); + this.name = "CliProcessCleanupError"; + } +} + +export const trackCliChild = (child: ChildProcess): ChildProcess => { + if (childStates.has(child)) return child; + let resolveClose!: () => void; + // Keep the process group id at spawn time. `child.pid` is no longer a useful + // source of truth after the leader exits while one of its descendants lives. + const state: ChildState = { + closed: false, + close: new Promise((resolve) => { resolveClose = resolve; }), + ...(process.platform === "win32" || child.pid === undefined ? {} : { pgid: child.pid }) + }; + child.once("close", () => { state.closed = true; resolveClose(); }); + childStates.set(child, state); + return child; +}; + +const groupExists = (pgid: number): boolean => { + try { + process.kill(-pgid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw new CliProcessCleanupError(`inspecting process group ${pgid}`, error); + } +}; + +type LinuxProcessState = Readonly<{ group: number; state: string }>; + +const parseLinuxProcessState = (stat: string): LinuxProcessState | undefined => { + const commandEnd = stat.lastIndexOf(")"); + if (commandEnd < 0) return undefined; + const fields = stat.slice(commandEnd + 1).trim().split(/\s+/u); + const group = Number(fields[2]); + return fields.length >= 3 && Number.isInteger(group) ? { state: fields[0]!, group } : undefined; +}; + +const linuxGroupHasLiveMember = async (pgid: number, procRoot = "/proc"): Promise => { + let entries: string[]; + try { entries = await readdir(procRoot); } catch { return undefined; } + let found = false; + for (const entry of entries) { + if (!/^\d+$/u.test(entry)) continue; + try { + const processState = parseLinuxProcessState(await readFile(`${procRoot}/${entry}/stat`, "utf8")); + if (processState?.group !== pgid) continue; + found = true; + if (processState.state !== "Z" && processState.state !== "X") return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return undefined; + } + } + return found ? false : undefined; +}; + +const groupSettled = async (pgid: number): Promise => { + if (!groupExists(pgid)) return true; + if (process.platform !== "linux") return false; + return await linuxGroupHasLiveMember(pgid) === false; +}; + +const signalGroup = (pgid: number, signal: NodeJS.Signals): boolean => { + try { + process.kill(-pgid, signal); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw new CliProcessCleanupError(`signaling process group ${pgid}`, error); + } +}; + +const waitForGroupSettled = async (pgid: number, milliseconds: number): Promise => { + const deadline = Date.now() + milliseconds; + while (true) { + try { + if (await groupSettled(pgid)) return true; + } catch (error) { + // EPERM is not evidence that a group is settled. Keep waiting for + // ESRCH or, on Linux, a /proc snapshot containing only exited zombies. + if (!isPermissionError(error) || Date.now() >= deadline) throw error; + } + if (Date.now() >= deadline) return false; + await delay(10); + } +}; + +const isPermissionError = (error: unknown): boolean => + error instanceof CliProcessCleanupError + && (error.cause as NodeJS.ErrnoException | undefined)?.code === "EPERM"; + +export const terminateChild = async (child: ChildProcess): Promise => { + trackCliChild(child); + const state = childStates.get(child)!; + const pgid = state.pgid; + if (pgid !== undefined && !await groupSettled(pgid)) { + if (signalGroup(pgid, "SIGTERM") && !await waitForGroupSettled(pgid, 1_000)) { + signalGroup(pgid, "SIGKILL"); + if (!await waitForGroupSettled(pgid, 1_000)) { + throw new CliProcessCleanupError(`waiting for process group ${pgid} to exit`); + } + } + } else if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + } + // A `close` event proves that Node released its pipes. ESRCH, or a Linux + // process-group snapshot containing only exited zombies, proves that no + // detached descendant can still execute. Both are required for success. + if (!state.closed) await state.close; + if (pgid !== undefined && !await groupSettled(pgid)) { + throw new CliProcessCleanupError(`waiting for process group ${pgid} to exit`); + } +}; diff --git a/src/pi/cliSession.test.ts b/src/pi/cliSession.test.ts index c3d8aaf..c20ff9d 100644 --- a/src/pi/cliSession.test.ts +++ b/src/pi/cliSession.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { createServer } from "node:http"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer, Server } from "node:http"; +import { access, chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; @@ -8,7 +8,7 @@ import { pathToFileURL } from "node:url"; import test from "node:test"; import { PiHarnessAdapter, type PiSessionFactory } from "./piHarness.js"; -import { createCliSessionFactory, readChild, renderCodexArgs, spawnEngine } from "./cliSession.js"; +import { CLI_ENGINE_MAX_OUTPUT_BYTES, createCliSessionFactory, readChild, renderCodexArgs, spawnEngine } from "./cliSession.js"; import { formatWorldWakePrompt } from "./worldNudge.js"; const require = createRequire(import.meta.url); @@ -194,6 +194,38 @@ test("CLI engine failures include bounded redacted diagnostics", async () => { } }); +test("CLI output cap counts combined multibyte stdout and stderr and quiesces descendants", async (context) => { + if (!requirePosixProcessGroups(context)) return; + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-output-cap-")); + const pidFile = path.join(root, "descendant.pid"); + const engine = path.join(root, "overflow.mjs"); + await writeFile(engine, [ + "import { spawn } from 'node:child_process';", + "import { writeFileSync } from 'node:fs';", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify("process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000)")}], { stdio: 'ignore' });`, + `writeFileSync(${JSON.stringify(pidFile)}, String(child.pid));`, + "process.on('SIGTERM', () => undefined);", + "const output = '🐙'.repeat(2 * 1024 * 1024);", + "process.stdout.write(output); process.stderr.write(output);", + "setInterval(() => undefined, 1000);" + ].join("\n")); + try { + const child = spawnEngine({ + command: process.execPath, commandArgs: [engine], engine: "agy", maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" + }, "overflow", { cwd: root }, undefined); + await assert.rejects(readChild(child, 10_000, []), (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, `CLI engine output exceeded ${CLI_ENGINE_MAX_OUTPUT_BYTES} bytes`); + assert.ok(error.message.length < 120); + return true; + }); + const descendantPid = Number(await readFile(pidFile, "utf8")); + assert.throws(() => process.kill(descendantPid, 0), { code: "ESRCH" }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("codex child stdin EPIPE does not replace the engine exit diagnostic", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-epipe-")); const stub = path.join(root, "early-exit-engine.mjs"); @@ -215,3 +247,152 @@ test("codex child stdin EPIPE does not replace the engine exit diagnostic", asyn await rm(root, { recursive: true, force: true }); } }); + +test("protected host control variables never reach Codex, Grok, or AGY children", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-control-token-")); + const controlEnv = "DAIMON_HOST_CONTROL_TOKEN_CANARY"; + const controlToken = "must-never-reach-engine"; + const unrelatedEnv = "DAIMON_UNRELATED_HOST_CANARY"; + const modelEnv = "OPENAI_API_KEY"; + process.env[controlEnv] = controlToken; + process.env[unrelatedEnv] = "must-never-reach-engine"; + process.env[modelEnv] = "must-never-reach-engine"; + const probe = path.join(root, "probe.mjs"); + await writeFile(probe, `#!/usr/bin/env node\nprocess.stdout.write([process.env.${controlEnv} ?? "absent", process.env.${unrelatedEnv} ?? "absent", process.env.${modelEnv} ?? "absent", process.env.CODEX_HOME ?? process.env.GROK_HOME ?? process.env.ANTIGRAVITY_CLI_HOME ?? "missing"].join("|"));`); + await chmod(probe, 0o700); + try { + for (const engine of ["codex", "grok", "agy"] as const) { + const engineHomePath = path.join(root, engine, engine === "codex" ? ".codex" : engine === "grok" ? ".grok" : ".antigravity-cli"); + const options = engine === "agy" + ? { engine, command: probe, commandArgs: [], maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" as const, redactedEnvironmentNames: [controlEnv], engineHomePath } + : { engine, command: probe, commandArgs: [], maxToolTurns: 1, timeoutMs: 10_000, redactedEnvironmentNames: [controlEnv], engineHomePath }; + const { session } = await createCliSessionFactory(options)({ cwd: root, runtimeHomePath: path.join(root, engine) }); + let output = ""; + session.subscribe((event) => { + if (event.type === "turn_end" && "content" in event.message && Array.isArray(event.message.content)) { + output = event.message.content.filter((item) => item.type === "text").map((item) => item.text).join(""); + } + }); + await session.prompt("probe"); + assert.equal(output, `absent|absent|absent|${engineHomePath}`); + await session.disposeAsync?.(); + } + } finally { + delete process.env[controlEnv]; + delete process.env[unrelatedEnv]; + delete process.env[modelEnv]; + await rm(root, { recursive: true, force: true }); + } + void context; +}); + +test("disposing a CLI session kills a stubborn process group before returning", async (context) => { + if (!requirePosixProcessGroups(context)) return; + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-stop-")); + const stubborn = path.join(root, "stubborn.mjs"); + const ready = path.join(root, "ready"); + await writeFile(stubborn, `#!/usr/bin/env node\nimport { writeFileSync } from 'node:fs'; process.on('SIGTERM', () => undefined); writeFileSync(${JSON.stringify(ready)}, 'ready'); setInterval(() => undefined, 1000);`); + await chmod(stubborn, 0o700); + try { + const { session } = await createCliSessionFactory({ + engine: "agy", command: stubborn, commandArgs: [], maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" + })({ cwd: root }); + const running = session.prompt("hold"); + void running.catch(() => undefined); + await waitForFile(ready); + const started = Date.now(); + await session.disposeAsync?.(); + assert.ok(Date.now() - started >= 900); + await assert.rejects(running); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("disposing during MCP setup prevents an engine process from spawning", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-mcp-cancel-")); + const marker = path.join(root, "engine-started"); + const engine = path.join(root, "engine.mjs"); + await writeFile(engine, `import { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(marker)}, "started");`); + try { + let session: Awaited>>["session"] | undefined; + const factory = createCliSessionFactory({ + command: process.execPath, commandArgs: [engine], engine: "codex", maxToolTurns: 1, timeoutMs: 10_000, + onToolsMounted: () => session?.dispose() + }); + ({ session } = await factory({ cwd: root })); + await assert.rejects(session.prompt("cancel"), /cancelled|disposed/); + await session.disposeAsync?.(); + await assert.rejects(access(marker)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("disposing from a Server.prototype.listen interleaving never leaves an MCP listener", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-mcp-listen-race-")); + const prototype = Server.prototype as unknown as { + listen: (this: Server, ...args: unknown[]) => Server; + }; + const originalListen = prototype.listen; + let activeSession: Awaited>>["session"] | undefined; + let intercepted: Server | undefined; + prototype.listen = function (this: Server, ...args: unknown[]): Server { + intercepted = this; + activeSession?.dispose(); + return originalListen.apply(this, args); + }; + try { + for (let attempt = 0; attempt < 32; attempt += 1) { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: ["-e", "process.exit(0)"], engine: "codex", maxToolTurns: 1, timeoutMs: 10_000 + })({ cwd: root }); + activeSession = session; + intercepted = undefined; + await assert.rejects(session.prompt(`cancel-${attempt}`), /cancelled|disposed/); + await session.disposeAsync?.(); + const mounted = intercepted as Server | undefined; + assert.ok(mounted, "MCP listener was not intercepted"); + assert.equal(mounted.listening, false); + assert.equal(mounted.address(), null); + } + } finally { + prototype.listen = originalListen; + await rm(root, { recursive: true, force: true }); + } +}); + +test("disposing during Grok registration terminates setup before the engine starts", async (context) => { + if (!requirePosixProcessGroups(context)) return; + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-grok-cancel-")); + const ready = path.join(root, "add-ready"); + const marker = path.join(root, "engine-started"); + const grok = path.join(root, "grok.mjs"); + await writeFile(grok, `import { writeFileSync } from "node:fs"; const args = process.argv.slice(2); if (args.includes("add")) { writeFileSync(${JSON.stringify(ready)}, "ready"); process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000); } else if (args.includes("remove")) process.exit(0); else writeFileSync(${JSON.stringify(marker)}, "started");`); + try { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 + })({ cwd: root }); + const pending = session.prompt("cancel"); + void pending.catch(() => undefined); + await waitForFile(ready); + await session.disposeAsync?.(); + await assert.rejects(pending); + await assert.rejects(access(marker)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function waitForFile(filePath: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { await access(filePath); return; } catch { await new Promise((resolve) => setTimeout(resolve, 10)); } + } + throw new Error("child did not become ready"); +} + +function requirePosixProcessGroups(context: { skip(message?: string): void }): boolean { + if (process.platform !== "win32") return true; + context.skip("detached process groups are not available on Windows"); + return false; +} diff --git a/src/pi/cliSession.ts b/src/pi/cliSession.ts index 9b411df..0b276c3 100644 --- a/src/pi/cliSession.ts +++ b/src/pi/cliSession.ts @@ -1,46 +1,68 @@ import { randomUUID } from "node:crypto"; import { createServer, type Server } from "node:http"; -import { spawn } from "node:child_process"; -import type { ChildProcess } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdir } from "node:fs/promises"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { createPiToolMcpServer } from "../mcp/toolServer.js"; +import { cliChildEnvironment } from "./cliEnvironment.js"; +import { renderCodexArgs, spawnEngine } from "./cliEngineSpawn.js"; +import { terminateChild, trackCliChild } from "./cliProcess.js"; import type { PiSessionLike } from "./piAgentHandle.js"; import type { PiSessionFactoryInput } from "./piHarness.js"; import { redactTraceText } from "./turnTrace.js"; export type CliEngineKind = "agy" | "codex" | "grok"; +/** Total stdout + stderr retained for one CLI invocation. */ +export const CLI_ENGINE_MAX_OUTPUT_BYTES = 64 * 1024; + export type CliEngineOptions = { readonly commandArgs?: readonly string[]; readonly command?: string; + /** Internal production authority: rechecked immediately before each child. */ + readonly verifyExecutable?: () => Promise; + readonly verifyRuntimePaths?: () => Promise; + readonly engineHomePath?: string; readonly maxToolTurns: number; readonly onToolsMounted?: (tools: readonly ToolDefinition[]) => void; readonly timeoutMs: number; + /** Daimon-owned identity envelope prepended exactly once to every wake. */ + readonly identityPrompt?: string; readonly redactedEnvironmentNames?: readonly string[]; } & ({ readonly engine: "codex" | "grok"; } | { + readonly dbusSessionBusAddress?: string; /** AGY has no MCP client. Selecting this state explicitly permits tool-free participation. */ readonly engine: "agy"; readonly toolAccess: "none"; }); -type SessionInput = { +export type CliSessionInput = { readonly cwd: string; readonly customTools?: ToolDefinition[]; readonly daimonSecretEnvironmentNames?: readonly string[]; + readonly runtimeHomePath?: string; }; type SessionEvent = Parameters[0] extends (event: infer Event) => void ? Event : never; type CliListener = Parameters[0]; type CliTurnEnd = Extract; -const childEnvironment = (redactedNames: readonly string[]): NodeJS.ProcessEnv => { - const redacted = new Set(redactedNames); - return Object.fromEntries(Object.entries(process.env).filter(([name]) => !redacted.has(name))); + +export const prepareCliRuntimeHome = async (runtimeHomePath: string | undefined): Promise => { + if (runtimeHomePath === undefined) return; + await Promise.all([ + runtimeHomePath, + `${runtimeHomePath}/.config`, + `${runtimeHomePath}/.local/share`, + `${runtimeHomePath}/.local/state`, + `${runtimeHomePath}/.cache`, + `${runtimeHomePath}/.tmp` + ].map((directory) => mkdir(directory, { recursive: true }))); }; const childSecretValues = (redactedNames: readonly string[]): readonly string[] => @@ -60,33 +82,61 @@ const childDiagnostic = (stdout: string, stderr: string, secretValues: readonly return redacted.length > 0 ? `: ${redacted}` : ""; }; -const terminate = (child: ChildProcess): void => { - if (!child.killed) child.kill("SIGTERM"); +const captureCleanup = async (current: unknown, action: () => Promise): Promise => { + try { await action(); } catch (error) { return current ?? error; } + return current; }; +export { terminateChild } from "./cliProcess.js"; + export const readChild = (child: ChildProcess, timeoutMs: number, secretValues: readonly string[]): Promise => new Promise((resolve, reject) => { + trackCliChild(child); const stdout: Buffer[] = []; const stderr: Buffer[] = []; - child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk)); - child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk)); + let bytes = 0; + let settled = false; + let cleanupStarted = false; + const settle = (action: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + action(); + }; + const abort = (error: Error): void => { + if (cleanupStarted) return; + cleanupStarted = true; + void terminateChild(child).then( + () => settle(() => reject(error)), + (cleanupError: unknown) => settle(() => reject(cleanupError instanceof Error ? cleanupError : error)) + ); + }; + const retain = (target: Buffer[], chunk: Buffer): void => { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += value.length; + if (bytes > CLI_ENGINE_MAX_OUTPUT_BYTES) { + abort(new Error(`CLI engine output exceeded ${CLI_ENGINE_MAX_OUTPUT_BYTES} bytes`)); + return; + } + target.push(value); + }; + child.stdout?.on("data", (chunk: Buffer) => retain(stdout, chunk)); + child.stderr?.on("data", (chunk: Buffer) => retain(stderr, chunk)); const timer = setTimeout(() => { - terminate(child); - reject(new Error("CLI engine timed out")); + abort(new Error("CLI engine timed out")); }, timeoutMs); child.once("error", (error) => { - clearTimeout(timer); - reject(error); + abort(error); }); child.once("close", (code, signal) => { - clearTimeout(timer); + if (cleanupStarted) return; if (code === 0) { - resolve(Buffer.concat(stdout).toString("utf8").trim()); + settle(() => resolve(Buffer.concat(stdout).toString("utf8").trim())); } else { - reject(new Error(`CLI engine exited ${code ?? signal}${childDiagnostic( + settle(() => reject(new Error(`CLI engine exited ${code ?? signal}${childDiagnostic( Buffer.concat(stdout).toString("utf8"), Buffer.concat(stderr).toString("utf8"), secretValues - )}`)); + )}`))); } }); }); @@ -95,98 +145,118 @@ const startMcp = async ( tools: ToolDefinition[], maxToolTurns: number, wakeDeadline: number, - onToolsMounted?: (tools: readonly ToolDefinition[]) => void + onToolsMounted: ((tools: readonly ToolDefinition[]) => void) | undefined, + onStarted: (mount: { endpoint: string; close: () => Promise }) => void ): Promise<{ endpoint: string; close: () => Promise }> => { onToolsMounted?.(tools); const mcpServer = createPiToolMcpServer(tools, { maxToolTurns, wakeDeadline }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); - await mcpServer.connect(transport); const httpServer: Server = createServer((request, response) => { void transport.handleRequest(request, response); }); + let lifecycle: "starting" | "listening" | "closing" | "closed" = "starting"; + let cancelled = false; + let endpoint = ""; + let closePromise: Promise | undefined; + let settleStartup!: () => void; + const startupSettled = new Promise((resolve) => { settleStartup = resolve; }); + const close = (): Promise => closePromise ??= (async () => { + cancelled = true; + if (lifecycle !== "closed") lifecycle = "closing"; + // `listen()` begins synchronously but its callback is pending. Waiting for + // startup prevents dispose from returning while that callback can still bind. + await startupSettled; + await transport.close().catch(() => undefined); + await mcpServer.close().catch(() => undefined); + if (httpServer.listening) await new Promise((resolve) => httpServer.close(() => resolve())); + lifecycle = "closed"; + })(); + const mount = { get endpoint(): string { return endpoint; }, close }; + onStarted(mount); + let startupError: unknown; try { + if (cancelled) throw new Error("MCP startup was cancelled"); + await mcpServer.connect(transport); + if (cancelled) throw new Error("MCP startup was cancelled"); await new Promise((resolve, reject) => { - httpServer.once("error", reject); - httpServer.listen(0, "127.0.0.1", () => resolve()); + const onError = (error: Error): void => reject(error); + httpServer.once("error", onError); + httpServer.listen(0, "127.0.0.1", () => { + httpServer.off("error", onError); + // A disposer can run from a Server.prototype.listen interceptor before + // this callback. Never publish the server as live in that interleaving. + if (!cancelled) lifecycle = "listening"; + resolve(); + }); }); + if (cancelled) throw new Error("MCP startup was cancelled"); + const address = httpServer.address(); + if (address === null || typeof address === "string") { + throw new Error("MCP server did not receive an ephemeral port"); + } + endpoint = `http://127.0.0.1:${address.port}/mcp`; + return mount; } catch (error) { - await transport.close().catch(() => undefined); - await mcpServer.close().catch(() => undefined); + startupError = error; throw error; + } finally { + settleStartup(); + if (cancelled || startupError !== undefined) await close(); } - const address = httpServer.address(); - if (address === null || typeof address === "string") { - await new Promise((resolve) => httpServer.close(() => resolve())); - await transport.close(); - await mcpServer.close(); - throw new Error("MCP server did not receive an ephemeral port"); - } - const close = async (): Promise => { - await transport.close().catch(() => undefined); - await mcpServer.close().catch(() => undefined); - await new Promise((resolve) => httpServer.close(() => resolve())); - }; - return { endpoint: `http://127.0.0.1:${address.port}/mcp`, close }; }; -const addGrokServer = async (endpoint: string, cwd: string, env: NodeJS.ProcessEnv, command: string, commandArgs: readonly string[], secretValues: readonly string[]): Promise => { - const child = spawn(command, [...commandArgs, "mcp", "add", "--transport", "http", "--scope", "project", "daimon", endpoint], { +type GrokRegistration = { close: () => Promise }; + +const grokCommand = async ( + args: readonly string[], cwd: string, env: NodeJS.ProcessEnv, command: string, secretValues: readonly string[], + onChild: (child: ChildProcess) => void, onChildSettled: (child: ChildProcess) => void +): Promise => { + const child = trackCliChild(spawn(command, args, { cwd, env, + detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] - }); - await readChild(child, 30_000, secretValues); + })); + onChild(child); + try { + await readChild(child, 30_000, secretValues); + } finally { + // The Grok CLI may let an auxiliary process outlive its leader. Do not + // release the tracked setup child until its detached group is quiescent. + await terminateChild(child); + onChildSettled(child); + } }; -export const renderCodexArgs = ( - options: Pick, - cwd: string, - endpoint: string | undefined, - sandbox: string = process.env.DAIMON_CODEX_SANDBOX ?? "danger-full-access" -): string[] => [...(options.commandArgs ?? []), "exec", "--sandbox", sandbox, "--skip-git-repo-check", "--color", "never", "-C", cwd, - "-c", `mcp_servers.daimon.url=${endpoint}`, "-"]; - -export const spawnEngine = ( - options: CliEngineOptions, - prompt: string, - input: SessionInput, - endpoint: string | undefined -): ChildProcess => { - const command: string = options.command ?? options.engine; - const env = childEnvironment([ - ...(options.redactedEnvironmentNames ?? []), - ...(input.daimonSecretEnvironmentNames ?? []) - ]); - if (options.engine === "codex") { - const args = renderCodexArgs(options, input.cwd, endpoint); - const child = spawn(command, args, { cwd: input.cwd, env, stdio: ["pipe", "pipe", "pipe"] }); - child.stdin.on("error", () => undefined); - child.stdin.write(prompt); - child.stdin.end(); - return child; - } - if (options.engine === "grok") { - return spawn(command, [...(options.commandArgs ?? []), "--single", prompt, "--max-turns", String(options.maxToolTurns), "--no-memory", - "--disable-web-search", "--cwd", input.cwd, "--output-format", "plain"], { - cwd: input.cwd, - env, - stdio: ["ignore", "pipe", "pipe"] - }); - } - return spawn(command, [...(options.commandArgs ?? []), "--print", prompt, "--print-timeout", `${options.timeoutMs}ms`], { - cwd: input.cwd, - env, - stdio: ["ignore", "pipe", "pipe"] - }); +const addGrokServer = async ( + endpoint: string, cwd: string, env: NodeJS.ProcessEnv, command: string, commandArgs: readonly string[], secretValues: readonly string[], + onChild: (child: ChildProcess) => void, onChildSettled: (child: ChildProcess) => void +): Promise => { + await grokCommand( + [...commandArgs, "mcp", "add", "--transport", "http", "--scope", "project", "daimon", endpoint], + cwd, env, command, secretValues, onChild, onChildSettled + ); + let closePromise: Promise | undefined; + return { + close: (): Promise => closePromise ??= grokCommand( + [...commandArgs, "mcp", "remove", "--scope", "project", "daimon"], + cwd, env, command, secretValues, onChild, onChildSettled + ) + }; }; class CliSession implements PiSessionLike { private readonly listeners = new Set(); + private readonly setupChildren = new Set(); private disposed = false; + private activeChild: ChildProcess | undefined; + private activeMount: { close: () => Promise } | undefined; + private grokRegistration: GrokRegistration | undefined; + private disposePromise: Promise | undefined; public constructor( private readonly options: CliEngineOptions, - private readonly input: SessionInput + private readonly input: CliSessionInput ) {} public subscribe(listener: CliListener): () => void { @@ -196,55 +266,90 @@ class CliSession implements PiSessionLike { public async prompt(text: string): Promise { if (this.disposed) throw new Error("CLI session is disposed"); + await prepareCliRuntimeHome(this.input.runtimeHomePath); const deadline = Date.now() + this.options.timeoutMs; const secretValues = childSecretValues([ ...(this.options.redactedEnvironmentNames ?? []), ...(this.input.daimonSecretEnvironmentNames ?? []) ]); const needsMcp = this.options.engine !== "agy"; - const mount = needsMcp - ? await startMcp(this.input.customTools ?? [], this.options.maxToolTurns, deadline, this.options.onToolsMounted) - : undefined; + let mount: { endpoint: string; close: () => Promise } | undefined; + let registration: GrokRegistration | undefined; let child: ChildProcess | undefined; + let output: string | undefined; + let cleanupFailure: unknown; try { + mount = needsMcp + ? await startMcp(this.input.customTools ?? [], this.options.maxToolTurns, deadline, this.options.onToolsMounted, (started) => { + this.activeMount = started; + if (this.disposed) void started.close(); + }) + : undefined; + this.ensureLive(); if (this.options.engine === "grok" && mount !== undefined) { - await addGrokServer(mount.endpoint, this.input.cwd, childEnvironment([ + await this.options.verifyExecutable?.(); + registration = await addGrokServer(mount.endpoint, this.input.cwd, cliChildEnvironment([ ...(this.options.redactedEnvironmentNames ?? []), ...(this.input.daimonSecretEnvironmentNames ?? []) - ]), this.options.command ?? "grok", this.options.commandArgs ?? [], secretValues); + ], this.input.runtimeHomePath, { engine: this.options.engine, executablePath: this.options.command, engineHomePath: this.options.engineHomePath }), this.options.command ?? "grok", this.options.commandArgs ?? [], secretValues, (setupChild) => { + this.setupChildren.add(setupChild); + }, (setupChild) => this.setupChildren.delete(setupChild)); + this.grokRegistration = registration; } - child = spawnEngine(this.options, text, this.input, mount?.endpoint); - const output = await readChild(child, Math.max(1, deadline - Date.now()), secretValues); - for (const listener of this.listeners) listener({ - type: "turn_end", - message: { - role: "assistant", - content: [{ type: "text", text: output }], - api: "openai-completions", - provider: "openai", - model: "cli", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } - }, - stopReason: "stop", - timestamp: Date.now() - }, - toolResults: [] - } satisfies CliTurnEnd); + this.ensureLive(); + await this.options.verifyRuntimePaths?.(); + await this.options.verifyExecutable?.(); + child = spawnEngine(this.options, `${this.options.identityPrompt ?? ""}${text}`, this.input, mount?.endpoint); + this.activeChild = child; + // Attach terminal listeners synchronously. A fast local sentinel may + // exit before an asynchronous post-spawn authority recheck completes. + const outputPromise = readChild(child, Math.max(1, deadline - Date.now()), secretValues); + await this.options.verifyRuntimePaths?.(); + await this.options.verifyExecutable?.(); + output = await outputPromise; } finally { - if (child !== undefined) terminate(child); - await mount?.close(); + cleanupFailure = await captureCleanup(cleanupFailure, async () => { if (child !== undefined) await terminateChild(child); }); + if (this.activeChild === child) this.activeChild = undefined; + let registrationClosed = registration === undefined; + cleanupFailure = await captureCleanup(cleanupFailure, async () => { await registration?.close(); registrationClosed = true; }); + if (registrationClosed && this.grokRegistration === registration) this.grokRegistration = undefined; + cleanupFailure = await captureCleanup(cleanupFailure, async () => { await mount?.close(); }); + if (this.activeMount === mount) this.activeMount = undefined; + if (cleanupFailure !== undefined) throw cleanupFailure; } + if (output === undefined) return; + for (const listener of this.listeners) listener({ + type: "turn_end", + message: { + role: "assistant", content: [{ type: "text", text: output }], api: "openai-completions", provider: "openai", model: "cli", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", timestamp: Date.now() + }, toolResults: [] + } satisfies CliTurnEnd); } public dispose(): void { this.disposed = true; this.listeners.clear(); + this.disposePromise ??= this.quiesce(); + } + + public async disposeAsync(): Promise { + this.dispose(); + await this.disposePromise; + } + + private ensureLive(): void { + if (this.disposed) throw new Error("CLI session is disposed"); + } + + private async quiesce(): Promise { + let cleanupFailure: unknown; + cleanupFailure = await captureCleanup(cleanupFailure, async () => { if (this.activeChild !== undefined) await terminateChild(this.activeChild); }); + cleanupFailure = await captureCleanup(cleanupFailure, () => Promise.all([...this.setupChildren].map((child) => terminateChild(child))).then(() => undefined)); + cleanupFailure = await captureCleanup(cleanupFailure, async () => { await this.grokRegistration?.close(); }); + cleanupFailure = await captureCleanup(cleanupFailure, async () => { await this.activeMount?.close(); }); + if (cleanupFailure !== undefined) throw cleanupFailure; } } @@ -256,44 +361,11 @@ export const createCliSessionFactory = (options: CliEngineOptions) => async ( session: new CliSession(options, { cwd: input.cwd, customTools: input.customTools, - daimonSecretEnvironmentNames: input.daimonSecretEnvironmentNames + daimonSecretEnvironmentNames: input.daimonSecretEnvironmentNames, + runtimeHomePath: input.runtimeHomePath }) }; }; -export interface EngineRunResult { - readonly durationMs: number; - readonly outputChars: number; - readonly promptChars: number; - readonly text: string; -} - -export const runEngineDetailed = async ( - engine: CliEngineKind, - prompt: string, - paths: { readonly workspacePath: string; readonly runtimeHomePath?: string } -): Promise => { - const startedAt = Date.now(); - const options: CliEngineOptions = engine === "agy" - ? { engine, maxToolTurns: 1, timeoutMs: 180_000, toolAccess: "none" } - : { engine, maxToolTurns: 2, timeoutMs: 180_000 }; - const session = new CliSession(options, { cwd: paths.workspacePath }); - let text = ""; - const unsubscribe = session.subscribe((event) => { - if (event.type !== "turn_end") return; - if (!("content" in event.message)) return; - text = Array.isArray(event.message.content) - ? event.message.content.filter((entry) => entry.type === "text").map((entry) => entry.text).join("") - : event.message.content; - }); - await session.prompt(prompt); - unsubscribe(); - session.dispose(); - return { durationMs: Date.now() - startedAt, outputChars: text.length, promptChars: prompt.length, text }; -}; - -export const runEngine = async ( - engine: CliEngineKind, - prompt: string, - paths: { readonly workspacePath: string; readonly runtimeHomePath?: string } -): Promise => (await runEngineDetailed(engine, prompt, paths)).text; +export { runEngine, runEngineDetailed, type EngineRunResult } from "./cliEngineRun.js"; +export { renderCodexArgs, spawnEngine } from "./cliEngineSpawn.js"; diff --git a/src/pi/cliSessionProcess.test.ts b/src/pi/cliSessionProcess.test.ts new file mode 100644 index 0000000..ffae48a --- /dev/null +++ b/src/pi/cliSessionProcess.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createCliSessionFactory, readChild, spawnEngine, terminateChild } from "./cliSession.js"; + +test("terminates a process group after its leader has exited", async (context) => { + if (!requirePosixProcessGroups(context)) return; + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-group-")); + const descendant = path.join(root, "descendant-pid"); + const leader = path.join(root, "leader.mjs"); + await writeFile(leader, `import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000)"], { stdio: "ignore" }); child.unref(); writeFileSync(${JSON.stringify(descendant)}, String(child.pid));`); + try { + const child = spawnEngine({ engine: "agy", command: process.execPath, commandArgs: [leader], maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" }, "exit", { cwd: root }, undefined); + await readChild(child, 10_000, []); + await waitForFile(descendant); + const pid = Number(await readFile(descendant, "utf8")); + await terminateChild(child); + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("Grok setup reaps a stubborn descendant after its successful leader exits", async (context) => { + if (!requirePosixProcessGroups(context)) return; + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-setup-group-")); + const descendant = path.join(root, "setup-descendant-pid"); + const grok = path.join(root, "grok.mjs"); + await writeFile(grok, `import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; + const args = process.argv.slice(2); + if (args.includes("add")) { + const child = spawn(process.execPath, ["-e", "import { writeFileSync } from 'node:fs'; process.on('SIGTERM', () => undefined); writeFileSync('ready', 'ready'); process.stdout.write('ready'); setInterval(() => undefined, 1000)"], { stdio: ["ignore", "pipe", "ignore"] }); + child.stdout.once("data", () => { writeFileSync(${JSON.stringify(descendant)}, String(child.pid)); process.exit(0); }); + } + if (args.includes("remove")) process.exit(0); + process.stdout.write("engine complete");`); + try { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 + })({ cwd: root }); + await session.prompt("research"); + const pid = Number(await readFile(descendant, "utf8")); + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); + await session.disposeAsync?.(); + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("Grok removal reaps a stubborn descendant after its successful leader exits", async (context) => { + if (!requirePosixProcessGroups(context)) return; + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-remove-group-")); + const descendant = path.join(root, "remove-descendant-pid"); + const grok = path.join(root, "grok.mjs"); + await writeFile(grok, `import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; + const args = process.argv.slice(2); + if (args.includes("remove")) { + const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => undefined); process.stdout.write('ready'); setInterval(() => undefined, 1000)"], { stdio: ["ignore", "pipe", "ignore"] }); + child.stdout.once("data", () => { writeFileSync(${JSON.stringify(descendant)}, String(child.pid)); process.exit(0); }); + } else if (args.includes("add")) process.exit(0); else process.stdout.write("engine complete");`); + try { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 + })({ cwd: root }); + await session.prompt("research"); + const pid = Number(await readFile(descendant, "utf8")); + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); + await session.disposeAsync?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function waitForFile(filePath: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { await access(filePath); return; } catch { await new Promise((resolve) => setTimeout(resolve, 10)); } + } + throw new Error("child did not become ready"); +} + +function requirePosixProcessGroups(context: { skip(message?: string): void }): boolean { + if (process.platform !== "win32") return true; + context.skip("detached process groups are not available on Windows"); + return false; +} diff --git a/src/pi/cliSessionRemoval.test.ts b/src/pi/cliSessionRemoval.test.ts new file mode 100644 index 0000000..886c5b1 --- /dev/null +++ b/src/pi/cliSessionRemoval.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createCliSessionFactory } from "./cliSession.js"; + +test("Grok removal failure rejects without emitting a successful turn", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-remove-failure-")); + const grok = path.join(root, "grok.mjs"); + await writeFile(grok, `const args = process.argv.slice(2); if (args.includes("remove")) process.exit(23); else if (args.includes("add")) process.exit(0); else process.stdout.write("engine complete");`); + try { + const { session } = await createCliSessionFactory({ command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 })({ cwd: root }); + let turns = 0; + session.subscribe((event) => { if (event.type === "turn_end") turns += 1; }); + await assert.rejects(session.prompt("research"), /CLI engine exited 23/); + assert.ok(session.disposeAsync); + await assert.rejects(session.disposeAsync(), /CLI engine exited 23/); + assert.equal(turns, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/pi/piAgentHandle.ts b/src/pi/piAgentHandle.ts index ea202c3..242bd97 100644 --- a/src/pi/piAgentHandle.ts +++ b/src/pi/piAgentHandle.ts @@ -1,5 +1,4 @@ import type { AgentHandle, AgentStatus, WakeEvent, WakeResult } from "../core/types.js"; - import { createTrustedPiMemoryToolContext, type PiMemoryToolContextRef } from "./memoryTools.js"; import { formatWakePrompt } from "./prompts.js"; import { @@ -20,6 +19,7 @@ import { formatWorldWakePrompt, worldWakeContext, type PiWorldToolContextRef } f import { createPiWorldTrajectoryCapture, type PiWorldTrajectoryIdentity } from "./worldTrajectory.js"; import { cloneWakeEvent, + disposePiSession, persistPiTurnArtifacts, PiWakeDeliveryQueue, selectPiSessionForWake, @@ -31,11 +31,8 @@ import { type WakeSessionSelection } from "./piAgentWakeSupport.js"; import { readMemoryContext, type MemoryPrepareTurnResult, type MemoryRuntime } from "@noopolis/mneme"; - export type { PiSession, PiSessionLike, PiSessionCreator, PiNativeSessionCreator } from "./piAgentWakeSupport.js"; - export type WakeAcceptanceInput = { runWake?: typeof stampTurnInputSubmitted; completeTurn?: typeof stampTurnOutputCompleted; traceTurn?: typeof persistPiTurnTrace; createWakeAcceptance?: (runtimeHomePath: string, agentId: string) => WakeAcceptanceStoreLike; }; - export class PiAgentHandle implements AgentHandle { private state: AgentStatus["state"] = "idle"; private lastWakeAt: string | undefined; @@ -44,7 +41,6 @@ export class PiAgentHandle implements AgentHandle { private readonly stampTurnInputSubmitted: typeof stampTurnInputSubmitted; private readonly stampTurnOutputCompleted: typeof stampTurnOutputCompleted; private readonly persistTrace: typeof persistPiTurnTrace; - constructor( id: string, session: PiSession, @@ -98,7 +94,6 @@ export class PiAgentHandle implements AgentHandle { new WakeAcceptanceStore(runtimeHomePath, id); this.wakeDeliveryQueue = new PiWakeDeliveryQueue(id, wakeAcceptance); } - async wake(event: WakeEvent): Promise { const wakeEvent = cloneWakeEvent(event); return this.wakeDeliveryQueue.wake( @@ -106,7 +101,6 @@ export class PiAgentHandle implements AgentHandle { (queuedEvent, transition) => this.runWake(queuedEvent, transition) ); } - private async runWake( event: WakeEvent, transitionToInvoking?: () => Promise @@ -123,11 +117,9 @@ export class PiAgentHandle implements AgentHandle { let unsubscribe: (() => void) | undefined; let stage = "select_session"; let prepared: MemoryPrepareTurnResult | undefined; - this.state = "running"; this.lastWakeAt = new Date().toISOString(); this.lastError = undefined; - const memoryContext = readMemoryContext({ kind: event.kind, id: event.id, @@ -153,7 +145,6 @@ export class PiAgentHandle implements AgentHandle { from: event.from, context: memoryContext }; - let promptText = worldContext === undefined ? formatWakePrompt(event) : safeWakeText; @@ -181,7 +172,6 @@ export class PiAgentHandle implements AgentHandle { tools, worldTrajectory }); - if (this.memory !== undefined) { stage = "memory_prepare"; const memoryStartedAt = Date.now(); @@ -194,7 +184,6 @@ export class PiAgentHandle implements AgentHandle { }; throw error; } - memoryPrepare = { durationMs: Date.now() - memoryStartedAt, prepared, @@ -214,11 +203,9 @@ export class PiAgentHandle implements AgentHandle { }); } } - if (selectedSession.mode === "dream") { promptText = formatDreamPrompt(promptText, selectedSession.threadId); } - stage = "causal_input"; const turnInput = await this.stampTurnInputSubmitted({ agentId: this.id, @@ -227,12 +214,10 @@ export class PiAgentHandle implements AgentHandle { promptText, runtimeHomePath: this.runtimeHomePath } satisfies StampTurnInputSubmittedInput); - stage = "invoking"; if (transitionToInvoking !== undefined) { await transitionToInvoking(); } - stage = "engine_prompt"; const engineStartedAt = Date.now(); await selectedSession.session.prompt(promptText, { expandPromptTemplates: false }); @@ -240,7 +225,6 @@ export class PiAgentHandle implements AgentHandle { this.state = "idle"; const outputText = chunks.join("\n").trim(); - stage = "causal_output"; await this.stampTurnOutputCompleted({ agentId: this.id, @@ -272,9 +256,6 @@ export class PiAgentHandle implements AgentHandle { if (rawTrainingCapture !== undefined && this.rawTrainingCaptureOptions !== undefined && this.piSessionForRawCapture !== undefined) { - // Do not retry a partially failed private capture in the catch path. - // The first failure is authoritative and retrying the same immutable - // turn path would only mask it with an EEXIST/partial-write error. rawTrainingCapturePersistAttempted = true; } await persistPiTurnArtifacts({ @@ -294,7 +275,6 @@ export class PiAgentHandle implements AgentHandle { worldTrajectory, worldTrajectoryIdentity: this.worldTrajectoryIdentity }); - return { agentId: this.id, text: outputText, @@ -389,9 +369,10 @@ export class PiAgentHandle implements AgentHandle { lastError: this.lastError }; } - async stop(): Promise { - this.session.dispose(); + // CLI-backed sessions may own a process group, while native Pi remains + // source-compatible through the synchronous dispose() hook. + await disposePiSession(this.session); this.state = "stopped"; } } diff --git a/src/pi/piAgentWakeSupport.ts b/src/pi/piAgentWakeSupport.ts index 773c807..73dae5c 100644 --- a/src/pi/piAgentWakeSupport.ts +++ b/src/pi/piAgentWakeSupport.ts @@ -37,7 +37,15 @@ export type PiSession = Awaited>["session" export interface PiSessionLike { subscribe(listener: Parameters[0]): () => void; prompt(text: string, options?: Parameters[1]): Promise; + /** Kept synchronous for existing Pi session implementations. */ dispose(): void; + /** Optional additive hook for sessions that must asynchronously quiesce children. */ + disposeAsync?(): Promise; +} + +export async function disposePiSession(session: PiSessionLike): Promise { + await Promise.resolve(session.dispose()); + await session.disposeAsync?.(); } export type PiSessionCreator = ( mode: MemoryWakeMode, diff --git a/src/pi/piHarness.ts b/src/pi/piHarness.ts index 128541b..0a84f09 100644 --- a/src/pi/piHarness.ts +++ b/src/pi/piHarness.ts @@ -3,10 +3,12 @@ import path from "node:path"; import { AuthStorage, + createBashTool, createAgentSession, type ModelRegistry, SessionManager, - SettingsManager + SettingsManager, + type ToolDefinition } from "@earendil-works/pi-coding-agent"; import { createMemoryRuntime, type MemoryAuthorityConfig } from "@noopolis/mneme"; @@ -44,6 +46,8 @@ type PiHarnessBaseOptions = { name: string; }; modelsPath?: string; + /** Host-only names removed from every engine and Pi bash child environment. */ + protectedEnvironmentNames?: readonly string[]; memory?: { authority?: MemoryAuthorityConfig; embeddingProvider?: HarnessMemoryEmbeddingProvider; @@ -68,6 +72,8 @@ export type PiHarnessOptions = PiHarnessBaseOptions & ( export type PiSessionFactoryInput = Exclude[0], undefined> & { daimonSecretEnvironmentNames?: readonly string[]; + /** The isolated runtime home assigned by Daimon to this one agent. */ + runtimeHomePath?: string; }; export type PiSessionFactory = (input: PiSessionFactoryInput) => Promise<{ session: PiSessionLike }>; @@ -85,7 +91,14 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { async startAgent(input: AgentStartInput): Promise { validatePiRawTrainingCaptureOptions(this.options.rawTrainingCapture); - await mkdir(input.runtimeHomePath, { recursive: true }); + await Promise.all([ + input.runtimeHomePath, + `${input.runtimeHomePath}/.config`, + `${input.runtimeHomePath}/.local/share`, + `${input.runtimeHomePath}/.local/state`, + `${input.runtimeHomePath}/.cache`, + `${input.runtimeHomePath}/.tmp` + ].map((directory) => mkdir(directory, { recursive: true }))); await mkdir(input.workspacePath, { recursive: true }); const memoryRuntimeHomePath = this.options.memory?.runtimeHomePath ?? input.runtimeHomePath; await mkdir(memoryRuntimeHomePath, { recursive: true }); @@ -132,6 +145,15 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { world: this.options.world, contextRef: worldToolContext }); + // Every child tool inherits the same denylist: host control values, + // Daimon-held world/model secrets, and caller-protected names. + const protectedNames = [...new Set([ + ...(this.options.protectedEnvironmentNames ?? []), + ...(this.options.world === undefined ? [] : [this.options.world.tokenEnv]) + ])]; + const protectedBash = protectedNames.length === 0 || input.tools?.includes("bash") === false + ? [] + : [createProtectedBashTool(input.workspacePath, input.runtimeHomePath, protectedNames)]; const toolNames = [ ...(input.tools ?? ["read", "write", "edit", "bash", "grep", "find", "ls"]), ...piMemoryToolNames(memoryTools), @@ -141,6 +163,7 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { return { cwd: input.workspacePath, agentDir: input.runtimeHomePath, + runtimeHomePath: input.runtimeHomePath, daimonSecretEnvironmentNames: this.options.world === undefined ? [] : [this.options.world.tokenEnv], authStorage: this.authStorage, modelRegistry: this.modelRegistry, @@ -151,7 +174,9 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { world: worldTools !== undefined }), tools: [...new Set(toolNames)], - customTools: worldTools === undefined ? memoryTools : [...memoryTools, ...worldTools], + customTools: worldTools === undefined + ? [...protectedBash, ...memoryTools] + : [...protectedBash, ...memoryTools, ...worldTools], sessionManager: SessionManager.create(input.workspacePath, sessionDirectory), settingsManager: SettingsManager.inMemory({ compaction: { enabled: false }, @@ -224,3 +249,31 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { ); } } + +function createProtectedBashTool(workspacePath: string, runtimeHomePath: string, protectedNames: readonly string[]): ToolDefinition { + const bash = createBashTool(workspacePath, { + spawnHook: (context) => ({ + ...context, + env: { + ...Object.fromEntries(Object.entries(context.env).filter(([name]) => !protectedNames.includes(name))), + HOME: runtimeHomePath, + XDG_CONFIG_HOME: `${runtimeHomePath}/.config`, + XDG_DATA_HOME: `${runtimeHomePath}/.local/share`, + XDG_STATE_HOME: `${runtimeHomePath}/.local/state`, + XDG_CACHE_HOME: `${runtimeHomePath}/.cache`, + TMPDIR: `${runtimeHomePath}/.tmp` + } + }) + }); + return { + name: bash.name, + label: bash.label, + description: bash.description, + parameters: bash.parameters, + ...(bash.prepareArguments === undefined ? {} : { prepareArguments: bash.prepareArguments }), + ...(bash.executionMode === undefined ? {} : { executionMode: bash.executionMode }), + async execute(toolCallId, params, signal, onUpdate) { + return bash.execute(toolCallId, params as Parameters[1], signal, onUpdate); + } + } as ToolDefinition; +} diff --git a/src/pi/piHarnessEnvironment.test.ts b/src/pi/piHarnessEnvironment.test.ts new file mode 100644 index 0000000..71493fe --- /dev/null +++ b/src/pi/piHarnessEnvironment.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { PiHarnessAdapter, type PiSessionFactory } from "./piHarness.js"; + +test("Pi bash children use each agent's isolated HOME/XDG paths and strip protected values", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-pi-protected-bash-")); + const tokenEnv = "DAIMON_PI_CONTROL_TOKEN_CANARY"; + const worldTokenEnv = "DAIMON_PI_WORLD_TOKEN_CANARY"; + const previousRunId = process.env.NOOPOLIS_RUN_ID; + process.env[tokenEnv] = "never-in-bash"; + process.env[worldTokenEnv] = "world-token-never-in-bash"; + process.env.NOOPOLIS_RUN_ID = "pi-environment-test"; + const captured: Parameters[0][] = []; + const factory: PiSessionFactory = async (input) => ({ + session: { + async prompt() { return undefined; }, + subscribe() { return () => undefined; }, + dispose() { return undefined; } + } + }); + try { + const adapter = new PiHarnessAdapter({ + authPath: path.join(root, "auth.json"), + model: { + auth: { method: "none" }, + endpoint: { baseUrl: "http://127.0.0.1:11434/v1", compatibility: "openai" }, + name: "stub", + provider: "stub" + }, + protectedEnvironmentNames: [tokenEnv], + world: { url: "http://world.invalid/v1/world", tokenEnv: worldTokenEnv }, + sessionFactory: async (input) => { + captured.push(input); + return factory(input); + } + }); + const homes = [path.join(root, "alpha-home"), path.join(root, "beta-home")]; + const handles = await Promise.all(homes.map((runtimeHomePath, index) => adapter.startAgent({ + id: `agent-${index}`, name: `Agent ${index}`, instructions: "test", workspacePath: root, runtimeHomePath + }))); + const command = `printf '%s|%s|%s|%s|%s|%s|%s|%s' "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" "$TMPDIR" "$${tokenEnv}" "$${worldTokenEnv}"`; + for (const input of captured) { + const index = homes.indexOf(input.runtimeHomePath!); + assert.notEqual(index, -1); + const bash = input.customTools?.find((tool) => tool.name === "bash"); + assert.ok(bash); + const result = JSON.stringify(await bash.execute("bash", { command }, undefined, undefined, {} as never)); + assert.match(result, new RegExp(homes[index]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.equal(result.includes("never-in-bash"), false); + assert.equal(result.includes("world-token-never-in-bash"), false); + assert.equal(result.includes(homes[1 - index]!), false); + } + await Promise.all(handles.map((handle) => handle.stop())); + } finally { + delete process.env[tokenEnv]; + delete process.env[worldTokenEnv]; + if (previousRunId === undefined) delete process.env.NOOPOLIS_RUN_ID; + else process.env.NOOPOLIS_RUN_ID = previousRunId; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/pi/piHarnessWorldTools.test.ts b/src/pi/piHarnessWorldTools.test.ts index 7ca1489..8f40922 100644 --- a/src/pi/piHarnessWorldTools.test.ts +++ b/src/pi/piHarnessWorldTools.test.ts @@ -200,7 +200,7 @@ test("a world binding appends exact token-free Pi tools and fails closed outside assert.ok(input); assert.deepEqual(input.tools, [...BASE_TOOLS, ...MEMORY_TOOLS, ...PI_WORLD_TOOL_NAMES]); const customTools = input.customTools as CapturedTool[]; - assert.deepEqual(customTools.map((tool) => tool.name), [...MEMORY_TOOLS, ...PI_WORLD_TOOL_NAMES]); + assert.deepEqual(customTools.map((tool) => tool.name), ["bash", ...MEMORY_TOOLS, ...PI_WORLD_TOOL_NAMES]); assert.deepEqual(world, { url: "http://simfile-world:19972/v1/world", tokenEnv }); assert.equal(requests.length, 0); diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md new file mode 100644 index 0000000..3ffe7c4 --- /dev/null +++ b/src/runtime/AGENTS.md @@ -0,0 +1,19 @@ +# Daimon organization-runtime contract + +This folder owns the versioned, organization-neutral contract and host for +isolated Daimon agents. It is not a compiler, organization graph, scheduler, +Moltnet adapter, or deployment surface. + +Keep config parsing pure and strict. The config must never contain credentials, +commands, argument arrays, arbitrary environment maps, process handles, or +caller-selected implementation hooks. The host may route only explicit +agent-id wakes and lifecycle operations; it cannot select, generate, or +coordinate wakes. + +Every source file stays below 400 lines. Keep tests beside the contract they +cover. + +`agySubscriptionRealm.ts` owns the one host-level private D-Bus/Secret Service +realm, durable keyring lease, bounded unlock stdin, and cleanup. +`agySubscriptionBootstrap.ts` owns only the interactive first-enrollment AGY +child; normal engine dispatch remains in `engineDispatcher.ts`. diff --git a/src/runtime/CLAUDE.md b/src/runtime/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/runtime/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/runtime/agySubscriptionBootstrap.test.ts b/src/runtime/agySubscriptionBootstrap.test.ts new file mode 100644 index 0000000..72af18d --- /dev/null +++ b/src/runtime/agySubscriptionBootstrap.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createAgyBootstrapInvocation, runAgySubscriptionBootstrap } from "./agySubscriptionBootstrap.js"; +import type { OrganizationRuntimeConfig } from "./organizationRuntime.js"; + +test("constructs an argument-free interactive AGY child with only Daimon's exact bus", () => { + const invocation = createAgyBootstrapInvocation({ + busAddress: "unix:path=/private/realm/bus", + executablePath: "/pinned/bin/agy", + runtimeHomePath: "/runtime/home", + workspacePath: "/workspace" + }); + assert.equal(invocation.command, "/pinned/bin/agy"); + assert.deepEqual(invocation.args, []); + assert.equal(invocation.env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/private/realm/bus"); + assert.equal(invocation.env.SSH_CONNECTION, undefined); + assert.equal(invocation.env.AGY_TOKEN, undefined); +}); + +test("runs interactive enrollment and the models proof only as AGY children", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-agy-bootstrap-")); + const workspacePath = path.join(root, "workspace"); + const runtimeHomePath = path.join(root, "runtime"); + const observation = path.join(root, "observation.jsonl"); + const executable = path.join(root, "agy"); + const previousPath = process.env.PATH; + try { + await Promise.all([ + mkdir(workspacePath, { mode: 0o700 }), + mkdir(runtimeHomePath, { mode: 0o700 }) + ]); + await writeFile(executable, [ + "#!/usr/bin/env node", + "import { appendFileSync } from 'node:fs';", + `appendFileSync(${JSON.stringify(observation)}, JSON.stringify({ args: process.argv.slice(2), bus: process.env.DBUS_SESSION_BUS_ADDRESS, home: process.env.HOME, ssh: Boolean(process.env.SSH_CONNECTION) }) + '\\n');`, + "if (process.argv.includes('--version')) process.stdout.write('agy 1.1.19');" + ].join("\n"), { mode: 0o700 }); + await chmod(executable, 0o700); + process.env.PATH = `${root}${path.delimiter}${previousPath ?? ""}`; + const config: OrganizationRuntimeConfig = { + version: "noopolis.daimon.organization-runtime.v1", + host: { bindHost: "127.0.0.1", port: 19700, controlTokenEnv: "CONTROL_TOKEN" }, + agents: [{ + id: "agent:agy", name: "AGY", instructions: "Work.", workspacePath, runtimeHomePath, + engine: { kind: "agy" } + }] + }; + let closed = 0; + await runAgySubscriptionBootstrap(config, async () => ({ + busAddress: "unix:path=/private/realm/bus", + close: async () => { closed += 1; } + })); + const records = (await readFile(observation, "utf8")).trim().split("\n").map((line) => JSON.parse(line) as { + args: string[]; bus?: string; home?: string; ssh: boolean; + }); + assert.deepEqual(records.map((record) => record.args), [["--version"], [], ["--version"], ["models"]]); + assert.equal(records.find((record) => record.args.length === 0)?.ssh, false); + assert.equal(records.find((record) => record.args.includes("models"))?.ssh, false); + assert.ok(records.every((record) => record.args.includes("--version") || record.bus === "unix:path=/private/realm/bus")); + const canonicalHome = await realpath(runtimeHomePath); + assert.ok(records.filter((record) => !record.args.includes("--version")).every((record) => record.home === canonicalHome)); + assert.equal(closed, 1); + } finally { + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/runtime/agySubscriptionBootstrap.ts b/src/runtime/agySubscriptionBootstrap.ts new file mode 100644 index 0000000..e51da58 --- /dev/null +++ b/src/runtime/agySubscriptionBootstrap.ts @@ -0,0 +1,98 @@ +import { spawn } from "node:child_process"; + +import { cliChildEnvironment } from "../pi/cliEnvironment.js"; +import { startAgySubscriptionRealm, type AgySubscriptionRealm } from "./agySubscriptionRealm.js"; +import { prepareEngineExecutable, verifyAgySubscriptionEnrollment } from "./engineReadiness.js"; +import type { OrganizationRuntimeConfig } from "./organizationRuntime.js"; +import { prepareOrganizationRuntimePaths } from "./physicalReadiness.js"; + +export type AgyBootstrapInvocation = Readonly<{ + args: readonly string[]; + command: string; + cwd: string; + env: NodeJS.ProcessEnv; +}>; + +export const createAgyBootstrapInvocation = (input: { + busAddress: string; + executablePath: string; + runtimeHomePath: string; + workspacePath: string; +}): AgyBootstrapInvocation => ({ + args: [], + command: input.executablePath, + cwd: input.workspacePath, + env: { + ...cliChildEnvironment([], input.runtimeHomePath, { + dbusSessionBusAddress: input.busAddress, + engine: "agy", + engineHomePath: input.runtimeHomePath, + executablePath: input.executablePath + }), + TERM: process.env.TERM ?? "xterm-256color" + } +}); + +export async function runAgySubscriptionBootstrap( + config: OrganizationRuntimeConfig, + startRealm: () => Promise = startAgySubscriptionRealm +): Promise { + const agy = config.agents.find((agent) => agent.engine.kind === "agy"); + if (agy === undefined) throw new Error("AGY bootstrap requires an AGY agent in the runtime config"); + const paths = await prepareOrganizationRuntimePaths(config.agents); + let realm: AgySubscriptionRealm | undefined; + let failure: unknown; + try { + realm = await startRealm(); + const canonical = paths.forAgent(agy); + await canonical.verify(); + const executable = await prepareEngineExecutable(agy.id, "agy"); + const invocation = createAgyBootstrapInvocation({ + busAddress: realm.busAddress, + executablePath: executable.executablePath, + runtimeHomePath: canonical.runtimeHomePath, + workspacePath: canonical.workspacePath + }); + const child = spawn(invocation.command, invocation.args, { + cwd: invocation.cwd, + env: invocation.env, + stdio: "inherit" + }); + const code = await waitForInteractiveChild(child); + if (code !== 0) throw new Error("AGY interactive subscription enrollment did not complete"); + await executable.verify(); + await verifyAgySubscriptionEnrollment( + agy.id, + executable.executablePath, + canonical.runtimeHomePath, + realm.busAddress + ); + await canonical.verify(); + } catch (error) { failure = error; } + const cleanup = await Promise.allSettled([ + realm?.close(), + paths.close() + ].filter((value): value is Promise => value !== undefined)); + const cleanupFailures = cleanup.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + if (cleanupFailures.length > 0) throw new AggregateError([ + ...(failure === undefined ? [] : [failure]), + ...cleanupFailures + ], "AGY bootstrap cleanup failed"); + if (failure !== undefined) throw failure; +} + +function waitForInteractiveChild(child: ReturnType): Promise { + return new Promise((resolve, reject) => { + const forward = (signal: NodeJS.Signals): void => { child.kill(signal); }; + const onInterrupt = (): void => forward("SIGINT"); + const onTerminate = (): void => forward("SIGTERM"); + const cleanup = (): void => { + process.off("SIGINT", onInterrupt); + process.off("SIGTERM", onTerminate); + }; + process.once("SIGINT", onInterrupt); + process.once("SIGTERM", onTerminate); + child.once("error", () => { cleanup(); reject(new Error("AGY interactive subscription enrollment could not start")); }); + child.once("close", (code) => { cleanup(); resolve(code); }); + }); +} diff --git a/src/runtime/agySubscriptionRealm.test.ts b/src/runtime/agySubscriptionRealm.test.ts new file mode 100644 index 0000000..239ce3f --- /dev/null +++ b/src/runtime/agySubscriptionRealm.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import { constants } from "node:fs"; +import { access, chmod, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; + +import { + createAgyRealmCommands, + readAgyRealmUnlockSecret, + startAgySubscriptionRealm +} from "./agySubscriptionRealm.js"; + +const execFile = promisify(execFileCallback); + +test("constructs a private Secret Service realm without unlock bytes in commands", () => { + const commands = createAgyRealmCommands({ + busSocketPath: "/ephemeral/bus", + controlDirectory: "/ephemeral/keyring" + }); + assert.deepEqual(commands.bus.args, ["--session", "--nofork", "--nopidfile", "--address=unix:path=/ephemeral/bus"]); + assert.deepEqual(commands.keyring.args, ["--foreground", "--components=secrets", "--unlock", "--control-directory=/ephemeral/keyring"]); + assert.equal(commands.lease.command, "/bin/sh"); + assert.deepEqual(commands.lease.args, [ + "-c", + "\"$1\" --exclusive --nonblock --conflict-exit-code 73 3 || exit 73; printf 'ready\\n'; IFS= read -r _daimon_hold || :", + "daimon-agy-lease", + "flock" + ]); + assert.equal(JSON.stringify(commands).includes("unlock-canary"), false); +}); + +test("accepts only one caller-owned 0600 bounded unlock file and redacts failures", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-agy-unlock-")); + const secretPath = path.join(root, "operator-secret-canary"); + const aliasPath = path.join(root, "alias"); + try { + await writeFile(secretPath, "unlock-canary", { mode: 0o600 }); + await chmod(secretPath, 0o600); + const bytes = await readAgyRealmUnlockSecret(secretPath); + assert.equal(bytes.toString("utf8"), "unlock-canary"); + bytes.fill(0); + await symlink(secretPath, aliasPath); + await assert.rejects(readAgyRealmUnlockSecret(aliasPath), (error: Error) => { + assert.equal(error.message, "AGY subscription realm unlock secret is unavailable or unsafe"); + assert.doesNotMatch(error.message, /operator-secret-canary|unlock-canary/); + return true; + }); + await chmod(secretPath, 0o640); + await assert.rejects(readAgyRealmUnlockSecret(secretPath), /unavailable or unsafe/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("starts, unlocks through stdin, cleans ephemeral state, and preserves durable state", async () => { + const fixture = await createRealmFixture(); + try { + const realm = await startAgySubscriptionRealm(fixture.options); + assert.match(realm.busAddress, /^unix:path=/u); + const socketPath = realm.busAddress.slice("unix:path=".length); + await access(socketPath); + await realm.close(); + await realm.close(); + await assert.rejects(access(socketPath)); + assert.equal(await readFile(path.join(fixture.durablePath, "sentinel"), "utf8"), "durable"); + assert.equal(await readFile(fixture.unlockObservation, "utf8"), "stdin-only:13"); + } finally { + await fixture.close(); + } +}); + +test("rejects a concurrent durable-volume holder", async (context) => { + if (process.platform !== "linux") { + context.skip("util-linux flock integration is Linux-only"); + return; + } + const fixture = await createRealmFixture({ realFlock: true }); + let first: Awaited> | undefined; + try { + first = await startAgySubscriptionRealm(fixture.options); + await assert.rejects(startAgySubscriptionRealm(fixture.options), /already in use or cannot be leased/); + } finally { + await first?.close(); + await fixture.close(); + } +}); + +test("real Linux Secret Service realm unlocks the same encrypted keyring after restart", async (context) => { + if (process.platform !== "linux" || !await commandsAvailable(["dbus-daemon", "dbus-send", "flock", "gnome-keyring-daemon"])) { + context.skip("real D-Bus, Secret Service, and flock integration is unavailable"); + return; + } + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "daimon-agy-real-realm-"))); + const durablePath = path.join(root, "durable"); + const temporaryRoot = path.join(root, "temporary"); + const unlockSecretPath = path.join(root, "unlock"); + await Promise.all([mkdir(durablePath, { mode: 0o700 }), mkdir(temporaryRoot, { mode: 0o700 })]); + await writeFile(unlockSecretPath, "integration-canary", { mode: 0o600 }); + await chmod(unlockSecretPath, 0o600); + const options = { durablePath, temporaryRoot, unlockSecretPath }; + try { + const first = await startAgySubscriptionRealm(options); + assert.equal(await loginCollectionLocked(first.busAddress), false); + await first.close(); + await first.close(); + const second = await startAgySubscriptionRealm(options); + assert.equal(await loginCollectionLocked(second.busAddress), false); + await second.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function commandsAvailable(commands: string[]): Promise { + const directories = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + for (const command of commands) { + let found = false; + for (const directory of directories) { + try { await access(path.join(directory, command), constants.X_OK); found = true; break; } catch { /* keep looking */ } + } + if (!found) return false; + } + return true; +} + +async function loginCollectionLocked(busAddress: string): Promise { + const { stdout } = await execFile("dbus-send", [ + `--bus=${busAddress}`, + "--dest=org.freedesktop.secrets", + "--print-reply", + "/org/freedesktop/secrets/collection/login", + "org.freedesktop.DBus.Properties.Get", + "string:org.freedesktop.Secret.Collection", + "string:Locked" + ], { timeout: 2_000 }); + if (/boolean false/u.test(stdout)) return false; + if (/boolean true/u.test(stdout)) return true; + throw new Error("Secret Service did not report the login collection lock state"); +} + +async function createRealmFixture(input: { realFlock?: boolean } = {}): Promise<{ + close(): Promise; + durablePath: string; + options: Parameters[0]; + unlockObservation: string; +}> { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "daimon-agy-realm-fixture-"))); + const durablePath = path.join(root, "durable"); + const temporaryRoot = path.join(root, "temporary"); + const unlockSecretPath = path.join(root, "unlock"); + const unlockObservation = path.join(root, "unlock-observation"); + const bus = path.join(root, "fake-dbus.mjs"); + const keyring = path.join(root, "fake-keyring.mjs"); + const lease = path.join(root, "fake-flock.mjs"); + await Promise.all([ + mkdir(durablePath, { mode: 0o700 }), + mkdir(temporaryRoot, { mode: 0o700 }) + ]); + await writeFile(path.join(durablePath, "sentinel"), "durable"); + await writeFile(unlockSecretPath, "unlock-canary", { mode: 0o600 }); + await chmod(unlockSecretPath, 0o600); + await writeFile(bus, [ + "#!/usr/bin/env node", + "import { writeFileSync } from 'node:fs';", + "const address = process.argv.find((value) => value.startsWith('--address=unix:path='));", + "const socket = address.slice('--address=unix:path='.length); writeFileSync(socket, 'socket');", + "process.on('SIGTERM', () => process.exit(0)); setInterval(() => undefined, 1000);" + ].join("\n"), { mode: 0o700 }); + await writeFile(keyring, [ + "#!/usr/bin/env node", + "import { writeFileSync } from 'node:fs';", + "const control = process.argv.find((value) => value.startsWith('--control-directory=')).slice('--control-directory='.length);", + "const chunks=[]; for await (const chunk of process.stdin) chunks.push(chunk);", + `writeFileSync(${JSON.stringify(unlockObservation)}, 'stdin-only:' + Buffer.concat(chunks).length);`, + "writeFileSync(control + '/control', 'socket');", + "process.on('SIGTERM', () => process.exit(0)); setInterval(() => undefined, 1000);" + ].join("\n"), { mode: 0o700 }); + await writeFile(lease, [ + "#!/usr/bin/env node", + "process.exit(0);" + ].join("\n"), { mode: 0o700 }); + await Promise.all([chmod(bus, 0o700), chmod(keyring, 0o700), chmod(lease, 0o700)]); + return { + close: async () => rm(root, { recursive: true, force: true }), + durablePath, + options: { + dbusDaemon: bus, + durablePath, + ...(input.realFlock ? {} : { flock: lease }), + gnomeKeyringDaemon: keyring, + socketReady: async (socketPath) => { await access(socketPath); return true; }, + temporaryRoot: `${temporaryRoot}${path.sep}`, + unlockSecretPath + }, + unlockObservation + }; +} diff --git a/src/runtime/agySubscriptionRealm.ts b/src/runtime/agySubscriptionRealm.ts new file mode 100644 index 0000000..29ecec8 --- /dev/null +++ b/src/runtime/agySubscriptionRealm.ts @@ -0,0 +1,271 @@ +import { constants } from "node:fs"; +import { spawn, type ChildProcess } from "node:child_process"; +import { chmod, lstat, mkdir, mkdtemp, open, realpath, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { terminateChild, trackCliChild } from "../pi/cliProcess.js"; +import { AGY_SUBSCRIPTION_REALM } from "./contractManifest.js"; + +const START_TIMEOUT_MS = 5_000; + +export type AgySubscriptionRealm = Readonly<{ + busAddress: string; + close(): Promise; +}>; + +export type AgySubscriptionRealmOptions = Readonly<{ + dbusDaemon?: string; + durablePath?: string; + flock?: string; + gnomeKeyringDaemon?: string; + shell?: string; + socketReady?: (socketPath: string) => Promise; + temporaryRoot?: string; + unlockSecretPath?: string; +}>; + +type Identity = Readonly<{ dev: number; ino: number; mode: number; nlink: number; size: number; uid: number }>; +type NumericStats = Readonly<{ + dev: number | bigint; ino: number | bigint; mode: number | bigint; nlink: number | bigint; + size: number | bigint; uid: number | bigint; + isFile(): boolean; + isSymbolicLink(): boolean; +}>; + +export const createAgyRealmCommands = (input: { + busSocketPath: string; + controlDirectory: string; + dbusDaemon?: string; + flock?: string; + gnomeKeyringDaemon?: string; + shell?: string; +}): Readonly<{ + bus: { command: string; args: string[] }; + keyring: { command: string; args: string[] }; + lease: { command: string; args: string[] }; +}> => ({ + bus: { + command: input.dbusDaemon ?? "dbus-daemon", + args: ["--session", "--nofork", "--nopidfile", `--address=unix:path=${input.busSocketPath}`] + }, + keyring: { + command: input.gnomeKeyringDaemon ?? "gnome-keyring-daemon", + args: ["--foreground", "--components=secrets", "--unlock", `--control-directory=${input.controlDirectory}`] + }, + lease: { + command: input.shell ?? "/bin/sh", + args: [ + "-c", + "\"$1\" --exclusive --nonblock --conflict-exit-code 73 3 || exit 73; printf 'ready\\n'; IFS= read -r _daimon_hold || :", + "daimon-agy-lease", + input.flock ?? "flock" + ] + } +}); + +export async function startAgySubscriptionRealm( + options: AgySubscriptionRealmOptions = {} +): Promise { + const durablePath = options.durablePath ?? AGY_SUBSCRIPTION_REALM.durableMountPath; + const unlockSecretPath = options.unlockSecretPath ?? AGY_SUBSCRIPTION_REALM.unlockMountPath; + await verifyPrivateDirectory(durablePath); + const unlockBytes = await readAgyRealmUnlockSecret(unlockSecretPath); + const ephemeralPath = await mkdtemp(path.join(options.temporaryRoot ?? os.tmpdir(), "daimon-agy-realm-")); + await chmod(ephemeralPath, 0o700); + const busSocketPath = path.join(ephemeralPath, "bus"); + const controlDirectory = path.join(ephemeralPath, "keyring"); + await mkdir(controlDirectory, { mode: 0o700 }); + const commands = createAgyRealmCommands({ + busSocketPath, + controlDirectory, + dbusDaemon: options.dbusDaemon, + flock: options.flock, + gnomeKeyringDaemon: options.gnomeKeyringDaemon, + shell: options.shell + }); + const children: ChildProcess[] = []; + let closePromise: Promise | undefined; + try { + const lease = await acquireLease(durablePath, commands.lease); + children.push(lease); + const bus = ownedSpawn(commands.bus.command, commands.bus.args, realmEnvironment(durablePath, ephemeralPath), ["ignore", "ignore", "ignore"]); + children.push(bus); + await waitForSocket(busSocketPath, bus, options.socketReady); + const busAddress = `unix:path=${busSocketPath}`; + const environment = { ...realmEnvironment(durablePath, ephemeralPath), DBUS_SESSION_BUS_ADDRESS: busAddress }; + const keyring = ownedSpawn(commands.keyring.command, commands.keyring.args, environment, ["pipe", "ignore", "ignore"]); + children.push(keyring); + await deliverUnlockSecret(keyring, unlockBytes); + await waitForSocket(path.join(controlDirectory, "control"), keyring, options.socketReady); + + const close = (): Promise => closePromise ??= cleanup(children, ephemeralPath); + return { busAddress, close }; + } catch (error) { + try { + await cleanup(children, ephemeralPath); + } catch (cleanupError) { + throw redactRealmFailure(new AggregateError([error, cleanupError], "AGY subscription realm startup cleanup failed")); + } + throw redactRealmFailure(error); + } finally { + unlockBytes.fill(0); + } +} + +export async function readAgyRealmUnlockSecret(sourcePath: string): Promise { + let handle: Awaited> | undefined; + try { + const before = await lstat(sourcePath); + assertUnlockIdentity(before); + handle = await open(sourcePath, constants.O_RDONLY | noFollow()); + const opened = await handle.stat(); + assertUnlockIdentity(opened); + if (!sameIdentity(identity(before), identity(opened))) throw new Error("changed"); + const bytes = Buffer.alloc(Number(opened.size)); + const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); + if (bytesRead !== bytes.length || bytes.includes(0) || bytes.includes(10) || bytes.includes(13)) { + bytes.fill(0); + throw new Error("invalid bytes"); + } + return bytes; + } catch { + throw new Error("AGY subscription realm unlock secret is unavailable or unsafe"); + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function verifyPrivateDirectory(directory: string): Promise { + try { + const before = await lstat(directory); + if (!before.isDirectory() || before.isSymbolicLink() || before.uid !== process.getuid?.() + || (before.mode & 0o777) !== AGY_SUBSCRIPTION_REALM.directoryMode) throw new Error("unsafe"); + const canonical = await realpath(directory); + if (canonical !== path.resolve(directory)) throw new Error("linked"); + } catch { + throw new Error("AGY subscription realm durable state is unavailable or unsafe"); + } +} + +async function acquireLease( + durablePath: string, + command: { command: string; args: string[] } +): Promise { + const lockPath = path.join(durablePath, ".daimon-lease"); + let lock: Awaited> | undefined; + try { + lock = await open(lockPath, constants.O_CREAT | constants.O_RDWR | noFollow(), 0o600); + const entry = await lock.stat(); + if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() + || entry.nlink !== 1 || (entry.mode & 0o777) !== 0o600) throw new Error("unsafe lease"); + const child = ownedSpawn(command.command, command.args, leaseEnvironment(), ["pipe", "pipe", "ignore", lock.fd]); + try { + await waitForReady(child); + return child; + } catch (error) { + try { await terminateChild(child); } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "lease acquisition cleanup failed"); + } + throw error; + } + } catch { + throw new Error("AGY subscription realm is already in use or cannot be leased"); + } finally { + await lock?.close().catch(() => undefined); + } +} + +async function deliverUnlockSecret(keyring: ChildProcess, secret: Buffer): Promise { + if (keyring.stdin === null) throw new Error("keyring input is unavailable"); + await new Promise((resolve, reject) => { + const fail = (): void => reject(new Error("keyring stopped before unlock")); + keyring.once("close", fail); + keyring.once("error", fail); + keyring.stdin!.once("error", fail); + keyring.stdin!.end(secret, () => { + keyring.off("close", fail); + keyring.off("error", fail); + keyring.stdin!.off("error", fail); + resolve(); + }); + }); +} + +function ownedSpawn( + command: string, + args: readonly string[], + env: NodeJS.ProcessEnv, + stdio: Parameters[2] extends infer _Options ? Array<"ignore" | "pipe" | number> : never +): ChildProcess { + return trackCliChild(spawn(command, args, { + detached: process.platform !== "win32", + env, + stdio + })); +} + +async function waitForSocket( + socketPath: string, + child: ChildProcess, + socketReady?: (socketPath: string) => Promise +): Promise { + const deadline = Date.now() + START_TIMEOUT_MS; + while (Date.now() < deadline) { + if (child.exitCode !== null || child.signalCode !== null) throw new Error("bus stopped"); + try { + if (socketReady ? await socketReady(socketPath) : await defaultSocketReady(socketPath)) return; + } catch { /* not ready */ } + await delay(20); + } + throw new Error("bus startup timed out"); +} + +async function defaultSocketReady(socketPath: string): Promise { + const entry = await lstat(socketPath); + return entry.isSocket() && entry.uid === process.getuid?.(); +} + +function waitForReady(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("lease startup timed out")), START_TIMEOUT_MS); + child.stdout?.once("data", (chunk: Buffer) => { + clearTimeout(timer); + if (chunk.toString("utf8") === "ready\n") resolve(); else reject(new Error("lease response invalid")); + }); + child.once("close", () => { clearTimeout(timer); reject(new Error("lease rejected")); }); + child.once("error", () => { clearTimeout(timer); reject(new Error("lease failed")); }); + }); +} + +async function cleanup(children: ChildProcess[], ephemeralPath: string): Promise { + const failures: unknown[] = []; + for (const child of [...children].reverse()) { + child.stdin?.end(); + try { await terminateChild(child); } catch (error) { failures.push(error); } + } + try { await rm(ephemeralPath, { recursive: true, force: true }); } catch (error) { failures.push(error); } + if (failures.length > 0) throw new AggregateError(failures, "AGY subscription realm cleanup failed"); +} + +const realmEnvironment = (durablePath: string, ephemeralPath: string): NodeJS.ProcessEnv => ({ + HOME: durablePath, + LANG: "C", + LC_ALL: "C", + PATH: process.env.PATH, + XDG_DATA_HOME: durablePath, + XDG_RUNTIME_DIR: ephemeralPath +}); +const leaseEnvironment = (): NodeJS.ProcessEnv => ({ LANG: "C", LC_ALL: "C", PATH: process.env.PATH }); +const delay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); +const noFollow = (): number => (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; +const identity = (value: NumericStats): Identity => ({ dev: Number(value.dev), ino: Number(value.ino), mode: Number(value.mode) & 0o7777, nlink: Number(value.nlink), size: Number(value.size), uid: Number(value.uid) }); +const sameIdentity = (left: Identity, right: Identity): boolean => left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.nlink === right.nlink && left.size === right.size && left.uid === right.uid; +const assertUnlockIdentity = (entry: NumericStats): void => { + const value = identity(entry); + if (!entry.isFile() || entry.isSymbolicLink() || value.uid !== process.getuid?.() || value.nlink !== 1 + || (value.mode & 0o777) !== AGY_SUBSCRIPTION_REALM.fileMode || value.size < 1 || value.size > AGY_SUBSCRIPTION_REALM.maxUnlockBytes) throw new Error("unsafe"); +}; +const redactRealmFailure = (error: unknown): Error => error instanceof Error && /^AGY subscription realm/u.test(error.message) + ? error + : new Error("AGY subscription realm could not be started", { cause: error }); diff --git a/src/runtime/cli.test.ts b/src/runtime/cli.test.ts new file mode 100644 index 0000000..f5ea766 --- /dev/null +++ b/src/runtime/cli.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { ORGANIZATION_RUNTIME_VERSION } from "./organizationRuntime.js"; +import { parseOrganizationRuntimeCliArguments, runOrganizationRuntimeCli } from "./cli.js"; + +test("CLI parses only normal run and the bounded interactive AGY bootstrap", () => { + assert.deepEqual(parseOrganizationRuntimeCliArguments(["run", "--config", "/config"]), { configPath: "/config", kind: "run" }); + assert.deepEqual(parseOrganizationRuntimeCliArguments(["auth", "agy", "login", "--config", "/config"]), { configPath: "/config", kind: "agy-login" }); + assert.throws(() => parseOrganizationRuntimeCliArguments(["auth", "codex", "login", "--config", "/config"]), /usage/); + assert.throws(() => parseOrganizationRuntimeCliArguments(["auth", "agy", "export", "--config", "/config"]), /usage/); +}); + +test("CLI rejects an oversized config before JSON parsing", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-runtime-config-cap-")); + const configPath = path.join(root, "runtime.json"); + try { + await writeFile(configPath, " ".repeat(1_048_577)); + await assert.rejects(runOrganizationRuntimeCli(["run", "--config", configPath]), /too large/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("CLI strictly authenticates and routes a production Daimon engine", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-runtime-cli-")); + const port = await availablePort(); + const tokenEnv = "DAIMON_RUNTIME_CLI_TEST_TOKEN"; + const token = "cli-control-token"; + const workspace = path.join(root, "workspace"); + const runtimeHome = path.join(root, "runtime"); + const acceptanceStore = path.join(root, "acceptance-store"); + const supportsC0 = process.platform === "linux"; + const program = path.join(root, "codex"); + const configPath = path.join(root, "runtime.json"); + await mkdir(workspace, { recursive: true, mode: 0o700 }); + await mkdir(runtimeHome, { recursive: true, mode: 0o700 }); + await mkdir(acceptanceStore, { recursive: true, mode: 0o700 }); + await mkdir(path.join(runtimeHome, ".codex"), { recursive: true, mode: 0o700 }); + await writeFile(path.join(runtimeHome, ".codex", "auth.json"), JSON.stringify({ tokens: { access_token: "test-access", refresh_token: "test-refresh" } }), { mode: 0o600 }); + await chmod(path.join(runtimeHome, ".codex", "auth.json"), 0o600); + await writeFile(program, `#!/usr/bin/env node\nif (process.argv.includes('--version')) process.stdout.write('test'); else { process.stdin.resume(); process.stdin.on('end', () => process.stdout.write(process.env.${tokenEnv} ?? 'absent')); }`); + await chmod(program, 0o700); + await writeFile(configPath, JSON.stringify({ + version: ORGANIZATION_RUNTIME_VERSION, + host: { bindHost: "0.0.0.0", port, controlTokenEnv: tokenEnv }, + agents: [{ + id: "agent", name: "Agent", instructions: "Respond.", workspacePath: workspace, runtimeHomePath: runtimeHome, + engine: { kind: "codex" } + }] + })); + const child = spawn(process.execPath, ["--import", "tsx", "src/runtime/cli.ts", "run", "--config", configPath], { + cwd: process.cwd(), env: { ...process.env, PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`, [tokenEnv]: token, ...(supportsC0 ? { DAIMON_RUNTIME_ACCEPTANCE_STORE: acceptanceStore } : {}), NOOPOLIS_RUN_ID: "runtime-cli-test" }, stdio: ["ignore", "pipe", "pipe"] + }); + const output: Buffer[] = []; + child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr?.on("data", (chunk: Buffer) => output.push(chunk)); + try { + await waitForHealth(port, token, child, output); + const readiness = await fetch(`http://127.0.0.1:${port}/healthz`); + assert.equal(readiness.status, 200); + assert.deepEqual(await readiness.json(), { status: "ok" }); + const unauthorized = await fetch(`http://127.0.0.1:${port}/v1/health`); + assert.equal(unauthorized.status, 401); + const malformed = await fetch(`http://127.0.0.1:${port}/v1/wake`, { + method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ agentId: "agent" }) + }); + assert.equal(malformed.status, 400); + const wrongContentType = await fetch(`http://127.0.0.1:${port}/v1/wake`, { + method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "text/plain" }, body: "{}" + }); + assert.equal(wrongContentType.status, 400); + const looseTimestamp = await fetch(`http://127.0.0.1:${port}/v1/wake`, { + method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ agentId: "agent", event: { + version: "noopolis.daimon.wake.v1", id: "loose", kind: "manual", text: "wake", occurredAt: "2026-08-17T00:00:00Z" + } }) + }); + assert.equal(looseTimestamp.status, 400); + const unknown = await requestWake(port, token, "unknown", "unknown-wake"); + assert.equal(unknown.status, 409); + assert.equal((await unknown.json() as { code: string }).code, "unknown_agent"); + const response = await requestWake(port, token, "agent", "valid-wake"); + assert.equal(response.status, 200); + assert.equal((await response.json() as { text: string }).text, "absent"); + const accepted = await fetch(`http://127.0.0.1:${port}/v2/wakes`, { + method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ agent_id: "agent", delivery_id: "delivery-1", event: { + version: "noopolis.daimon.wake.v2", kind: "manual", text: "wake", occurred_at: "2026-08-17T00:00:00.000Z" + } }) + }); + if (!supportsC0) { assert.equal(accepted.status, 404); return; } + assert.equal(accepted.status, 202); + const acceptance = await accepted.json() as { acceptance_id: string; state: string; text?: string }; + assert.equal(acceptance.state, "accepted"); + assert.equal(acceptance.text, undefined); + await waitForReceipt(port, token, acceptance.acceptance_id); + const conflict = await fetch(`http://127.0.0.1:${port}/v2/wakes`, { + method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ agent_id: "agent", delivery_id: "delivery-1", event: { + version: "noopolis.daimon.wake.v2", kind: "manual", text: "different", occurred_at: "2026-08-17T00:00:00.000Z" + } }) + }); + assert.equal(conflict.status, 409); + } finally { + child.kill("SIGTERM"); + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => child.once("exit", () => resolve())); + } + await rm(root, { recursive: true, force: true }); + } +}); + +async function requestWake(port: number, token: string, agentId: string, id: string): Promise { + return fetch(`http://127.0.0.1:${port}/v1/wake`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ agentId, event: { + version: "noopolis.daimon.wake.v1", id, kind: "manual", text: "wake", occurredAt: "2026-08-17T00:00:00.000Z" + } }) + }); +} + +async function availablePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("no test port"); + await new Promise((resolve) => server.close(() => resolve())); + return address.port; +} + +async function waitForHealth(port: number, token: string, child: ReturnType, output: Buffer[]): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/health`, { headers: { authorization: `Bearer ${token}` } }); + if (response.status === 200) return; + } catch { /* process has not bound yet */ } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`runtime CLI did not become healthy: ${Buffer.concat(output).toString("utf8")}; exit=${child.exitCode ?? child.signalCode ?? "running"}`); +} + +async function waitForReceipt(port: number, token: string, acceptanceId: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const response = await fetch(`http://127.0.0.1:${port}/v2/wake-receipts/${acceptanceId}`, { headers: { authorization: `Bearer ${token}` } }); + if (response.status === 200 && (await response.json() as { state: string }).state === "completed") return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error("v2 acceptance did not reach a terminal result"); +} diff --git a/src/runtime/cli.ts b/src/runtime/cli.ts new file mode 100644 index 0000000..4c00adc --- /dev/null +++ b/src/runtime/cli.ts @@ -0,0 +1,216 @@ +#!/usr/bin/env node +import { readFile, realpath, stat } from "node:fs/promises"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { pathToFileURL } from "node:url"; + +import { createOrganizationRuntimeHost } from "./organizationRuntimeHost.js"; +import { createOrganizationRuntimeControlHost } from "./organizationRuntimeControl.js"; +import { runAgySubscriptionBootstrap } from "./agySubscriptionBootstrap.js"; +import { parseOrganizationRuntimeConfig, parseOrganizationRuntimeWakeRequest } from "./organizationRuntime.js"; + +const MAX_BODY_BYTES = 1_048_576; +const MAX_CONFIG_BYTES = 1_048_576; +const ACCEPTANCE_STORE_ENV = "DAIMON_RUNTIME_ACCEPTANCE_STORE"; + +export async function runOrganizationRuntimeCli(arguments_: readonly string[], environment = process.env): Promise { + const command = parseArguments(arguments_); + const config = parseOrganizationRuntimeConfig(JSON.parse(await readBoundedFile(command.configPath))); + if (command.kind === "agy-login") { + await runAgySubscriptionBootstrap(config); + return; + } + const storePath = environment[ACCEPTANCE_STORE_ENV]; + const control = storePath === undefined || !storePath.trim() + ? undefined + : createOrganizationRuntimeControlHost(config, { acceptanceStorePath: storePath, controlToken: environment[config.host.controlTokenEnv] }); + const host = control ?? createOrganizationRuntimeHost(config); + await host.start(); + + const server = createServer((request, response) => { + void handleRequest(request, response, host, control, environment[config.host.controlTokenEnv]); + }); + try { + await listen(server, config.host.bindHost, config.host.port); + } catch (error) { + await host.stop(); + throw error; + } + const sockets = new Set(); + server.on("connection", (socket) => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); }); + // Signals can arrive together (Docker stop commonly sends TERM then KILL). + // Every path shares one shutdown outcome and only the first initiates it. + let shutdown: Promise | undefined; + let serverClosed = false; + const stop = (): Promise => { + if (shutdown !== undefined) return shutdown; + const attempt = (async () => { + if (!serverClosed) { + await closeServer(server, sockets); + serverClosed = true; + } + await host.stop(); + process.removeListener("SIGINT", onSignal); + process.removeListener("SIGTERM", onSignal); + })(); + shutdown = attempt; + void attempt.catch(() => { if (shutdown === attempt) shutdown = undefined; }); + return attempt; + }; + const onSignal = (): void => { void stop().catch((error: unknown) => { process.stderr.write(`${error instanceof Error ? error.message : "runtime shutdown failed"}\n`); process.exitCode = 1; }); }; + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); +} + +export function parseOrganizationRuntimeCliArguments(arguments_: readonly string[]): Readonly<{ + configPath: string; + kind: "agy-login" | "run"; +}> { + if (arguments_.length === 3 && arguments_[0] === "run" && arguments_[1] === "--config" && arguments_[2]) { + return { configPath: arguments_[2], kind: "run" }; + } + if (arguments_.length === 5 && arguments_[0] === "auth" && arguments_[1] === "agy" && arguments_[2] === "login" + && arguments_[3] === "--config" && arguments_[4]) { + return { configPath: arguments_[4], kind: "agy-login" }; + } + throw new Error("usage: daimon-runtime run --config | daimon-runtime auth agy login --config "); +} + +const parseArguments = parseOrganizationRuntimeCliArguments; + +async function handleRequest( + request: import("node:http").IncomingMessage, + response: import("node:http").ServerResponse, + host: ReturnType, + control: ReturnType | undefined, + expectedToken: string | undefined +): Promise { + try { + const url = new URL(request.url ?? "/", "http://runtime.invalid"); + if (request.method === "GET" && url.pathname === "/healthz" && url.search === "") { + return respond(response, 200, { status: "ok" }); + } + if (!authorized(request, expectedToken)) return respond(response, 401, { error: "unauthorized" }); + if (request.method === "POST" && url.pathname === "/v1/wake") { + if (request.headers["content-type"] !== "application/json") throw new TypeError("content-type must be application/json"); + const result = await host.wake(parseOrganizationRuntimeWakeRequest({ ...bodyRecord(await readJson(request)), token: expectedToken })); + return respond(response, result.status === "completed" ? 200 : result.status === "failed" ? 502 : 409, result); + } + if (request.method === "GET" && url.pathname === "/v1/health") { + return respond(response, 200, await host.health(parseHealthQuery(url))); + } + if (request.method === "GET" && url.pathname === "/v1/activity") { + return respond(response, 200, await host.activity(parseActivityQuery(url))); + } + if (control !== undefined && request.method === "POST" && url.pathname === "/v2/wakes") { + if (request.headers["content-type"] !== "application/json") throw new TypeError("content-type must be application/json"); + const result = await control.accept({ ...bodyRecord(await readJson(request)), token: expectedToken }); + const status = result.state === "accepted" ? 202 : result.state === "stopped" ? 409 : result.code === "invalid_request" ? 400 : result.code === "unauthorized" ? 401 : 409; + return respond(response, status, result); + } + if (control !== undefined && request.method === "GET" && /^\/v2\/wake-receipts\/[0-9a-f-]+$/iu.test(url.pathname)) { + assertQuery(url, []); + const acceptanceId = url.pathname.slice("/v2/wake-receipts/".length); + const receipt = await control.wakeReceipt(expectedToken, acceptanceId); + return receipt === undefined ? respond(response, 404, { error: "not_found" }) : respond(response, 200, receipt); + } + return respond(response, 404, { error: "not_found" }); + } catch (error) { + return respond(response, 400, { error: error instanceof Error ? error.message : "invalid_request" }); + } +} + +function authorized(request: import("node:http").IncomingMessage, token: string | undefined): boolean { + if (token === undefined || !token.trim()) return false; + const actual = request.headers.authorization; + if (actual === undefined) return false; + const digest = (value: string): Buffer => createHash("sha256").update(value).digest(); + return timingSafeEqual(digest(`Bearer ${token}`), digest(actual)); +} + +async function readJson(request: import("node:http").IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += value.length; + if (size > MAX_BODY_BYTES) throw new TypeError("request body is too large"); + chunks.push(value); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function bodyRecord(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError("request body must be an object"); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) throw new TypeError("request body must be a plain own-properties object"); + return value as Record; +} + +function parseHealthQuery(url: URL): string | undefined { + assertQuery(url, ["agentId"]); + return url.searchParams.has("agentId") ? nonBlank(url.searchParams.get("agentId"), "agentId") : undefined; +} + +function parseActivityQuery(url: URL): { agentId?: string; cursor?: string; limit: number } { + assertQuery(url, ["agentId", "cursor", "limit"]); + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit === null ? 50 : Number(rawLimit); + if (!Number.isInteger(limit)) throw new TypeError("limit must be an integer"); + return { + ...(url.searchParams.has("agentId") ? { agentId: nonBlank(url.searchParams.get("agentId"), "agentId") } : {}), + ...(url.searchParams.has("cursor") ? { cursor: nonBlank(url.searchParams.get("cursor"), "cursor") } : {}), + limit + }; +} + +function assertQuery(url: URL, allowed: readonly string[]): void { + const seen = new Set(); + for (const [key] of url.searchParams) { + if (!allowed.includes(key) || seen.has(key)) throw new TypeError("query contains an unsupported or duplicate parameter"); + seen.add(key); + } +} + +function nonBlank(value: unknown, label: string): string { + if (typeof value !== "string") throw new TypeError(`${label} must be a string`); + const result = value; + if (!result.trim()) throw new TypeError(`${label} must not be blank`); + return result; +} + +async function readBoundedFile(filePath: string): Promise { + if ((await stat(filePath)).size > MAX_CONFIG_BYTES) throw new TypeError("runtime config is too large"); + const bytes = await readFile(filePath); + if (bytes.length > MAX_CONFIG_BYTES) throw new TypeError("runtime config is too large"); + return bytes.toString("utf8"); +} + +function respond(response: import("node:http").ServerResponse, status: number, value: unknown): void { + response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + response.end(`${JSON.stringify(value)}\n`); +} + +function listen(server: import("node:http").Server, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => resolve()); + }); +} + +async function closeServer(server: import("node:http").Server, sockets: Set): Promise { + const closed = new Promise((resolve, reject) => server.close((error) => error === undefined ? resolve() : reject(error))); + const timeout = setTimeout(() => { for (const socket of sockets) socket.destroy(); }, 10_000); + try { await closed; } finally { clearTimeout(timeout); } +} + +const invokedPath = process.argv[1] === undefined + ? undefined + : await realpath(process.argv[1]).catch(() => process.argv[1]); + +if (invokedPath !== undefined && import.meta.url === pathToFileURL(invokedPath).href) { + void runOrganizationRuntimeCli(process.argv.slice(2)).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : "daimon-runtime failed"}\n`); + process.exitCode = 1; + }); +} diff --git a/src/runtime/contractManifest.test.ts b/src/runtime/contractManifest.test.ts new file mode 100644 index 0000000..a663787 --- /dev/null +++ b/src/runtime/contractManifest.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { AGY_SUBSCRIPTION_REALM, canonicalJson, canonicalRuntimeContractManifest, ENGINE_CREDENTIAL_MATERIAL, RUNTIME_CONTRACT_MANIFEST } from "./contractManifest.js"; + +test("runtime contract manifest is data-only and deterministic", () => { + const first = canonicalRuntimeContractManifest(); + assert.equal(first, canonicalRuntimeContractManifest()); + assert.equal(first, canonicalJson(RUNTIME_CONTRACT_MANIFEST)); + assert.deepEqual(Object.keys(ENGINE_CREDENTIAL_MATERIAL), ["codex", "grok"]); + assert.deepEqual(RUNTIME_CONTRACT_MANIFEST.supportedEngineKinds, ["agy", "codex", "grok"]); + assert.equal(RUNTIME_CONTRACT_MANIFEST.agySubscriptionRealm, AGY_SUBSCRIPTION_REALM); + assert.equal(AGY_SUBSCRIPTION_REALM.directoryMode, 0o700); + assert.equal(AGY_SUBSCRIPTION_REALM.fileMode, 0o600); + assert.doesNotMatch(first, /antigravity-oauth-token|agy-auth/); + assert.doesNotMatch(first, /DBUS_SESSION_BUS_ADDRESS|gnome-keyring|dbus-daemon|argv|command|schedule|moltnet|credential value/i); + for (const rule of Object.values(ENGINE_CREDENTIAL_MATERIAL)) { + assert.equal(rule.directoryMode, 0o700); + assert.equal(rule.fileMode, 0o600); + assert.ok(!rule.sourceRelativePath.startsWith("/") && !rule.sourceRelativePath.includes("..")); + assert.ok(!rule.destinationRelativePath.startsWith("/") && !rule.destinationRelativePath.includes("..")); + } +}); diff --git a/src/runtime/contractManifest.ts b/src/runtime/contractManifest.ts new file mode 100644 index 0000000..0b129a7 --- /dev/null +++ b/src/runtime/contractManifest.ts @@ -0,0 +1,60 @@ +import { + ORGANIZATION_RUNTIME_CONFIG_SCHEMA, + ORGANIZATION_RUNTIME_MAX_AGENTS, + ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS +} from "./organizationRuntime.js"; + +export const RUNTIME_CONTRACT_MANIFEST_VERSION = "noopolis.daimon.runtime-contract-manifest.v1" as const; +export const ENGINE_CREDENTIAL_MATERIAL = { + codex: { sourceSlot: "codex-auth", sourceRelativePath: ".daimon-inbound/codex-auth", destinationRelativePath: ".codex/auth.json", directoryMode: 0o700, fileMode: 0o600 }, + grok: { sourceSlot: "grok-auth", sourceRelativePath: ".daimon-inbound/grok-auth", destinationRelativePath: ".grok/auth.json", directoryMode: 0o700, fileMode: 0o600 } +} as const; +export const AGY_SUBSCRIPTION_REALM = { + durableMountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", + unlockMountPath: "/var/lib/spawnfile/daimon/agy-unlock-secret", + unlockSourceSlot: "agy-unlock-secret", + directoryMode: 0o700, + fileMode: 0o600, + maxUnlockBytes: 4_096 +} as const; + +const MAX_DURATION_MS = 180_000; +const text = { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" } as const; +const boundedText = { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS } as const; +const timestamp = { type: "string", format: "date-time", pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" } as const; +const wakeEvent = { type: "object", additionalProperties: false, required: ["version", "id", "kind", "text", "occurredAt"], properties: { version: { const: "noopolis.daimon.wake.v1" }, id: text, kind: { enum: ["manual", "message", "external"] }, text: boundedText, occurredAt: timestamp } } as const; +const wakeResultBase = { version: { const: "noopolis.daimon.wake-result.v1" }, agentId: text, wakeId: text } as const; +const activityItem = { type: "object", additionalProperties: false, required: ["id", "agentId", "kind", "occurredAt"], properties: { id: { type: "string", format: "uuid", pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" }, agentId: text, wakeId: text, kind: { enum: ["wake_started", "wake_completed", "wake_rejected", "wake_aborted", "agent_stopped"] }, occurredAt: timestamp } } as const; + +export const RUNTIME_CONTRACT_MANIFEST = { + version: RUNTIME_CONTRACT_MANIFEST_VERSION, + consumedConfigFields: ["version", "host.bindHost", "host.port", "host.controlTokenEnv", "agents[].id", "agents[].name", "agents[].instructions", "agents[].workspacePath", "agents[].runtimeHomePath", "agents[].engine.kind"], + organizationRuntimeConfigSchema: ORGANIZATION_RUNTIME_CONFIG_SCHEMA, + supportedEngineKinds: ["agy", "codex", "grok"], + engineCredentialMaterial: ENGINE_CREDENTIAL_MATERIAL, + agySubscriptionRealm: AGY_SUBSCRIPTION_REALM, + wakeRequestSchema: { type: "object", additionalProperties: false, required: ["agentId", "event"], properties: { agentId: text, event: wakeEvent } }, + wakeResultSchema: { oneOf: [ + { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "text", "durationMs"], properties: { ...wakeResultBase, status: { const: "completed" }, text: boundedText, durationMs: { type: "integer", minimum: 0, maximum: MAX_DURATION_MS } } }, + { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { ...wakeResultBase, status: { const: "rejected" }, code: { enum: ["unauthorized", "unknown_agent", "queue_full"] } } }, + { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { version: { const: "noopolis.daimon.wake-result.v1" }, status: { const: "rejected" }, agentId: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS }, wakeId: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS }, code: { const: "invalid_request" } } }, + { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { ...wakeResultBase, status: { const: "stopped" }, code: { enum: ["host_stopping", "host_stopped", "queued_wake_stopped", "active_wake_aborted"] } } }, + { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { ...wakeResultBase, status: { const: "failed" }, code: { const: "engine_failed" } } } + ] }, + healthResponseSchema: { type: "object", additionalProperties: false, required: ["version", "state", "agents"], properties: { version: { const: "noopolis.daimon.organization-runtime-health.v1" }, state: { enum: ["starting", "running", "stopping", "stopped"] }, agents: { type: "array", maxItems: ORGANIZATION_RUNTIME_MAX_AGENTS, items: { type: "object", additionalProperties: false, required: ["agentId", "state"], properties: { agentId: text, state: { enum: ["starting", "running", "stopping", "stopped", "idle", "failed"] } } } } } }, + activityResponseSchema: { type: "object", additionalProperties: false, required: ["version", "items"], properties: { version: { const: "noopolis.daimon.organization-runtime-activity.v1" }, items: { type: "array", maxItems: 100, items: activityItem }, nextCursor: { type: "string", minLength: 1, maxLength: 16, pattern: "^(0|[1-9][0-9]{0,15})$" } } } +} as const; + +export type RuntimeContractManifest = typeof RUNTIME_CONTRACT_MANIFEST; +export type EngineCredentialKind = keyof typeof ENGINE_CREDENTIAL_MATERIAL; +export type EngineCredentialSlot = (typeof ENGINE_CREDENTIAL_MATERIAL)[EngineCredentialKind]["sourceSlot"]; + +export function canonicalRuntimeContractManifest(): string { return canonicalJson(RUNTIME_CONTRACT_MANIFEST); } + +export function canonicalJson(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value !== "object") throw new TypeError("manifest must contain only JSON data"); + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; +} diff --git a/src/runtime/engineDispatcher.test.ts b/src/runtime/engineDispatcher.test.ts new file mode 100644 index 0000000..21671d4 --- /dev/null +++ b/src/runtime/engineDispatcher.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { access, chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { startOrganizationRuntimeEngine } from "./engineDispatcher.js"; +import { ORGANIZATION_RUNTIME_VERSION, type OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +const rootConfig = (root: string, kind: OrganizationRuntimeAgentConfig["engine"]["kind"]): OrganizationRuntimeAgentConfig => ({ + id: `${kind}-agent`, name: kind, instructions: "Reply.", + workspacePath: path.join(root, "workspace", kind), runtimeHomePath: path.join(root, "runtime", kind), + engine: { kind } +}); + +test("production dispatcher starts each closed engine intent through Daimon", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-dispatcher-")); + const priorPath = process.env.PATH; + const priorRun = process.env.NOOPOLIS_RUN_ID; + const stub = `#!/usr/bin/env node\nconst args = process.argv.slice(2); if (args.includes("mcp")) process.stdout.write("ok"); else process.stdout.write(process.env.DAIMON_DISPATCH_CONTROL ?? "absent");`; + try { + for (const name of ["codex", "grok", "agy"]) { + const file = path.join(root, name); + await writeFile(file, stub); + await chmod(file, 0o700); + await seedAuth(root, name as "codex" | "grok" | "agy"); + } + process.env.PATH = `${root}${path.delimiter}${priorPath ?? ""}`; + process.env.NOOPOLIS_RUN_ID = "dispatcher-test"; + process.env.DAIMON_DISPATCH_CONTROL = "host-only"; + for (const kind of ["codex", "grok", "agy"] as const) { + const handle = await startOrganizationRuntimeEngine(rootConfig(root, kind), "DAIMON_DISPATCH_CONTROL", undefined, kind === "agy" ? "unix:path=/private/realm/bus" : undefined); + const result = await handle.wake({ id: `${kind}-wake`, kind: "manual", text: "probe" }); + assert.equal(result.text, "absent"); + await handle.stop(); + } + } finally { + if (priorPath === undefined) delete process.env.PATH; + else process.env.PATH = priorPath; + if (priorRun === undefined) delete process.env.NOOPOLIS_RUN_ID; + else process.env.NOOPOLIS_RUN_ID = priorRun; + delete process.env.DAIMON_DISPATCH_CONTROL; + await rm(root, { recursive: true, force: true }); + } +}); + +test("production dispatcher waits for active engine quiescence during shutdown", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-dispatcher-stop-")); + const priorPath = process.env.PATH; + const priorRun = process.env.NOOPOLIS_RUN_ID; + const ready = path.join(root, "ready"); + const agy = path.join(root, "agy"); + await writeFile(agy, `#!/usr/bin/env node\nimport { writeFileSync } from "node:fs"; if (process.argv.some((value) => value.includes("hold"))) { writeFileSync(${JSON.stringify(ready)}, "ready"); process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000); }`); + await chmod(agy, 0o700); + await seedAuth(root, "agy"); + try { + process.env.PATH = `${root}${path.delimiter}${priorPath ?? ""}`; + process.env.NOOPOLIS_RUN_ID = "dispatcher-stop-test"; + const handle = await startOrganizationRuntimeEngine(rootConfig(root, "agy"), "DAIMON_DISPATCH_CONTROL", undefined, "unix:path=/private/realm/bus"); + const pending = handle.wake({ id: "hold", kind: "manual", text: "hold" }); + void pending.catch(() => undefined); + await waitForFile(ready); + const started = Date.now(); + await handle.stop(); + assert.ok(Date.now() - started >= 900); + await assert.rejects(pending); + } finally { + if (priorPath === undefined) delete process.env.PATH; + else process.env.PATH = priorPath; + if (priorRun === undefined) delete process.env.NOOPOLIS_RUN_ID; + else process.env.NOOPOLIS_RUN_ID = priorRun; + await rm(root, { recursive: true, force: true }); + } +}); + +test("Daimon frames one escaped identity envelope for every production engine", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-dispatcher-identity-")); + const priorPath = process.env.PATH; + const priorRun = process.env.NOOPOLIS_RUN_ID; + const identity = { name: "Nora \"\"", instructions: "Follow \"quoted\" instructions.\n" }; + const cliStub = [ + "#!/usr/bin/env node", + "const args = process.argv.slice(2);", + "if (args.includes('mcp')) process.stdout.write('ok');", + "else if (args.includes('--single')) process.stdout.write(args[args.indexOf('--single') + 1]);", + "else if (args.includes('--print')) process.stdout.write(args[args.indexOf('--print') + 1]);", + "else { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); process.stdout.write(Buffer.concat(chunks).toString('utf8')); }" + ].join("\n"); + try { + for (const kind of ["codex", "grok", "agy"] as const) { + const file = path.join(root, kind); + await writeFile(file, cliStub); + await chmod(file, 0o700); + await seedAuth(root, kind); + } + process.env.PATH = `${root}${path.delimiter}${priorPath ?? ""}`; + process.env.NOOPOLIS_RUN_ID = "dispatcher-identity-test"; + for (const kind of ["codex", "grok", "agy"] as const) { + const base = rootConfig(root, kind); + const config: OrganizationRuntimeAgentConfig = { ...base, name: identity.name, instructions: identity.instructions }; + const handle = await startOrganizationRuntimeEngine(config, "DAIMON_UNUSED_CONTROL", undefined, kind === "agy" ? "unix:path=/private/realm/bus" : undefined); + const result = await handle.wake({ id: `${kind}-wake`, kind: "manual", text: "payload" }); + const envelope = JSON.stringify({ id: config.id, name: identity.name, instructions: identity.instructions }); + assert.equal(result.text.split(envelope).length - 1, 1); + assert.match(result.text, //); + assert.match(result.text, /payload/); + await handle.stop(); + } + } finally { + if (priorPath === undefined) delete process.env.PATH; + else process.env.PATH = priorPath; + if (priorRun === undefined) delete process.env.NOOPOLIS_RUN_ID; + else process.env.NOOPOLIS_RUN_ID = priorRun; + await rm(root, { recursive: true, force: true }); + } +}); + +async function waitForFile(filePath: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { await access(filePath); return; } catch { await new Promise((resolve) => setTimeout(resolve, 10)); } + } + throw new Error("engine did not become active"); +} + +async function seedAuth(root: string, kind: "codex" | "grok" | "agy"): Promise { + const directory = path.join(root, "runtime", kind, kind === "codex" ? ".codex" : kind === "grok" ? ".grok" : ".antigravity-cli"); + await mkdir(path.join(root, "workspace", kind), { recursive: true, mode: 0o700 }); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const file = path.join(directory, kind === "agy" ? "antigravity-oauth-token" : "auth.json"); + await writeFile(file, JSON.stringify({ tokens: { access_token: "test-access", refresh_token: "test-refresh" } }), { mode: 0o600 }); + await chmod(file, 0o600); +} diff --git a/src/runtime/engineDispatcher.ts b/src/runtime/engineDispatcher.ts new file mode 100644 index 0000000..592e5ef --- /dev/null +++ b/src/runtime/engineDispatcher.ts @@ -0,0 +1,85 @@ +import path from "node:path"; + +import type { AgentHandle } from "../core/types.js"; +import { createCliSessionFactory } from "../pi/cliSession.js"; +import { PiHarnessAdapter } from "../pi/piHarness.js"; + +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; +import type { OrganizationRuntimePathAuthority } from "./physicalReadiness.js"; +import { prepareEngineReadiness } from "./engineReadiness.js"; + +/** + * The production-only bridge from a closed runtime engine intent to Daimon's + * existing one-agent harness. Configuration never carries a command or env. + */ +export async function startOrganizationRuntimeEngine( + agent: OrganizationRuntimeAgentConfig, + controlTokenEnv: string, + paths?: ReturnType, + agyBusAddress?: string +): Promise { + await paths?.verify(); + const canonicalAgent = paths === undefined ? agent : { ...agent, workspacePath: paths.workspacePath, runtimeHomePath: paths.runtimeHomePath }; + const readiness = await prepareEngineReadiness(canonicalAgent, canonicalAgent.runtimeHomePath, agyBusAddress); + const adapter = adapterFor(canonicalAgent, controlTokenEnv, readiness.verify, readiness.executablePath, readiness.engineHomePath, paths?.verify, agyBusAddress); + const handle = await adapter.startAgent({ + id: canonicalAgent.id, + name: canonicalAgent.name, + instructions: canonicalAgent.instructions, + runtimeHomePath: canonicalAgent.runtimeHomePath, + workspacePath: canonicalAgent.workspacePath + }); + await paths?.verify(); + return { + ...handle, + async wake(event) { + await paths?.verify(); + await readiness.verify(); + try { return await handle.wake(event); } finally { await paths?.verify(); await readiness.verify(); } + }, + async stop() { await handle.stop(); }, + status: () => handle.status() + }; +} + +function adapterFor(agent: OrganizationRuntimeAgentConfig, controlTokenEnv: string, verifyExecutable: () => Promise, executablePath: string, engineHomePath: string, verifyRuntimePaths?: () => Promise, agyBusAddress?: string): PiHarnessAdapter { + const engine = agent.engine.kind; + const sessionFactory = createCliSessionFactory( + engine === "agy" + ? { engine, maxToolTurns: 1, timeoutMs: 180_000, toolAccess: "none", dbusSessionBusAddress: agyBusAddress, redactedEnvironmentNames: [controlTokenEnv], identityPrompt: identityEnvelope(agent), command: executablePath, engineHomePath, verifyExecutable, verifyRuntimePaths } + : { engine, maxToolTurns: 2, timeoutMs: 180_000, redactedEnvironmentNames: [controlTokenEnv], identityPrompt: identityEnvelope(agent), command: executablePath, engineHomePath, verifyExecutable, verifyRuntimePaths } + ); + return cliHarness(agent, sessionFactory, [controlTokenEnv]); +} + +/** + * CLI engines do not consume Pi's resource loader. Frame the same immutable + * identity in JSON so arbitrary names/instructions cannot change its shape. + */ +function identityEnvelope(agent: OrganizationRuntimeAgentConfig): string { + return [ + "", + JSON.stringify({ id: agent.id, name: agent.name, instructions: agent.instructions }), + "", + "The following is the current wake event." + ].join("\n") + "\n"; +} + +function cliHarness( + agent: OrganizationRuntimeAgentConfig, + sessionFactory: ReturnType, + protectedEnvironmentNames: readonly string[] +): PiHarnessAdapter { + return new PiHarnessAdapter({ + authPath: path.join(agent.runtimeHomePath, "auth.json"), + // Pi owns the harness envelope while the session factory owns this CLI. + model: { + auth: { method: "none" }, + endpoint: { baseUrl: "http://127.0.0.1/daimon-cli", compatibility: "openai" }, + name: "daimon-cli", + provider: "daimon-cli" + }, + sessionFactory, + protectedEnvironmentNames + }); +} diff --git a/src/runtime/engineReadiness.test.ts b/src/runtime/engineReadiness.test.ts new file mode 100644 index 0000000..86c33cb --- /dev/null +++ b/src/runtime/engineReadiness.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { ENGINE_CREDENTIAL_MATERIAL } from "./contractManifest.js"; +import { engineAuthFile, engineHomeName, prepareEngineReadiness } from "./engineReadiness.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +const agent = (root: string, kind: "codex" | "grok" | "agy"): OrganizationRuntimeAgentConfig => ({ + id: "safe-agent", name: "Safe", instructions: "Work.", workspacePath: path.join(root, "workspace"), runtimeHomePath: path.join(root, "home"), engine: { kind } +}); + +test("pins an executable and accepts only a private refreshable local auth artifact", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-engine-ready-")); + const previousPath = process.env.PATH; + try { + const config = agent(root, "codex"); + const executable = path.join(root, "codex"); + await mkdir(config.workspacePath, { recursive: true, mode: 0o700 }); + await mkdir(path.join(config.runtimeHomePath, ".codex"), { recursive: true, mode: 0o700 }); + await writeFile(executable, "#!/usr/bin/env node\nif (process.argv.includes('--version')) process.stdout.write('test');", { mode: 0o700 }); + await chmod(executable, 0o700); + await writeFile(path.join(config.runtimeHomePath, ".codex", "auth.json"), JSON.stringify({ tokens: { access_token: "not-logged", refresh_token: "not-logged" } }), { mode: 0o600 }); + await chmod(path.join(config.runtimeHomePath, ".codex", "auth.json"), 0o600); + process.env.PATH = `${root}${path.delimiter}${previousPath ?? ""}`; + const readiness = await prepareEngineReadiness(config, config.runtimeHomePath); + await readiness.verify(); + await rename(executable, `${executable}.replaced`); + await assert.rejects(readiness.verify(), /safe-agent codex is unavailable/); + } finally { + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + await rm(root, { recursive: true, force: true }); + } +}); + +test("fails closed for missing or unsafe credentials without reflecting their path or contents", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-engine-auth-")); + const previousPath = process.env.PATH; + try { + const config = agent(root, "agy"); + await mkdir(config.workspacePath, { recursive: true, mode: 0o700 }); + await mkdir(path.join(config.runtimeHomePath, ".antigravity-cli"), { recursive: true, mode: 0o700 }); + await writeFile(path.join(root, "agy"), "#!/usr/bin/env node\nif (process.argv.includes('--version')) process.stdout.write('test'); else process.exitCode = 1;", { mode: 0o700 }); + await chmod(path.join(root, "agy"), 0o700); + process.env.PATH = `${root}${path.delimiter}${previousPath ?? ""}`; + await assert.rejects(prepareEngineReadiness(config, config.runtimeHomePath, "unix:path=/private/realm/bus"), (error: Error) => { + assert.match(error.message, /safe-agent agy is unavailable/); + assert.match(error.message, /subscription enrollment is required/); + assert.doesNotMatch(error.message, /antigravity-oauth-token|daimon-engine-auth/); + return true; + }); + } finally { + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + await rm(root, { recursive: true, force: true }); + } +}); +test("verifies AGY through its noninteractive native secure-storage probe without a portable token file", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-engine-agy-native-")); + const previousPath = process.env.PATH; + try { + const config = agent(root, "agy"); + const probeLog = path.join(root, "probe-log"); + await Promise.all([ + mkdir(config.workspacePath, { recursive: true, mode: 0o700 }), + mkdir(config.runtimeHomePath, { recursive: true, mode: 0o700 }) + ]); + await writeFile(path.join(root, "agy"), [ + "#!/usr/bin/env node", + `import { appendFileSync } from "node:fs";`, + `if (process.argv.includes("--version")) process.stdout.write("1.1.19");`, + `else if (process.argv.includes("models")) appendFileSync(${JSON.stringify(probeLog)}, process.env.HOME + "|" + process.env.DBUS_SESSION_BUS_ADDRESS + "\\n");`, + `else process.exitCode = 2;` + ].join("\n"), { mode: 0o700 }); + await chmod(path.join(root, "agy"), 0o700); + process.env.PATH = `${root}${path.delimiter}${previousPath ?? ""}`; + + const readiness = await prepareEngineReadiness(config, config.runtimeHomePath, "unix:path=/private/realm/bus"); + await readiness.verify(); + + assert.equal(readiness.engineHomePath, config.runtimeHomePath); + assert.equal(await readFile(probeLog, "utf8"), `${config.runtimeHomePath}|unix:path=/private/realm/bus\n${config.runtimeHomePath}|unix:path=/private/realm/bus\n`); + assert.equal((ENGINE_CREDENTIAL_MATERIAL as Partial>).agy, undefined); + } finally { + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + await rm(root, { recursive: true, force: true }); + } +}); + +test("readiness derives every engine home, credential file, and required mode from the manifest", () => { + const root = "/runtime-home"; + for (const kind of ["codex", "grok"] as const) { + const rule = ENGINE_CREDENTIAL_MATERIAL[kind]; + const engineHome = path.join(root, engineHomeName(kind)); + assert.equal(engineHome, path.join(root, path.dirname(rule.destinationRelativePath))); + assert.equal(engineAuthFile(kind, engineHome), path.join(root, rule.destinationRelativePath)); + assert.equal(rule.directoryMode, 0o700); + assert.equal(rule.fileMode, 0o600); + } +}); diff --git a/src/runtime/engineReadiness.ts b/src/runtime/engineReadiness.ts new file mode 100644 index 0000000..11de80e --- /dev/null +++ b/src/runtime/engineReadiness.ts @@ -0,0 +1,224 @@ +import { constants } from "node:fs"; +import { lstat, open, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; + +import { cliChildEnvironment } from "../pi/cliEnvironment.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +const MAX_AUTH_BYTES = 64 * 1024; +const MAX_PROBE_BYTES = 8 * 1024; + +export type EngineReadiness = Readonly<{ + executablePath: string; + engineHomePath: string; + verify(): Promise; +}>; + +export type EngineExecutableReadiness = Readonly<{ + executablePath: string; + verify(): Promise; +}>; + +type FileIdentity = Readonly<{ dev: number; ino: number; size: number; mtimeMs: number; mode: number }>; + +/** + * Resolves and pins the small, local authority needed by one CLI engine. This + * deliberately does not start an agent, contact a provider, or read a secret + * beyond the bounded structural authentication check. + */ +export async function prepareEngineReadiness( + agent: OrganizationRuntimeAgentConfig, + runtimeHomePath: string, + agyBusAddress?: string +): Promise { + const executable = await prepareEngineExecutable(agent.id, agent.engine.kind); + const executablePath = executable.executablePath; + const engineHomePath = agent.engine.kind === "agy" ? runtimeHomePath : path.join(runtimeHomePath, engineHomeName(agent.engine.kind)); + await verifyEngineAuth(agent.id, agent.engine.kind, engineHomePath, executablePath, runtimeHomePath, agyBusAddress); + return { + executablePath, + engineHomePath, + async verify(): Promise { + await executable.verify(); + await verifyEngineAuth(agent.id, agent.engine.kind, engineHomePath, executablePath, runtimeHomePath, agyBusAddress); + } + }; +} + +export async function prepareEngineExecutable( + agentId: string, + engine: "codex" | "grok" | "agy" +): Promise { + const executablePath = await resolveExecutable(agentId, engine); + const executableIdentity = identity(await stat(executablePath)); + const capability = await probeExecutable(agentId, executablePath, engine); + return { + executablePath, + async verify(): Promise { + let current: Awaited>; + try { + const entry = await lstat(executablePath); + if (entry.isSymbolicLink()) throw new Error("link"); + current = await stat(executablePath); + } catch { throw unavailable(agentId, engine, "engine executable changed"); } + if (!sameIdentity(executableIdentity, identity(current)) || !current.isFile()) { + throw unavailable(agentId, engine, "engine executable changed"); + } + if (await probeExecutable(agentId, executablePath, engine) !== capability) { + throw unavailable(agentId, engine, "engine capability changed"); + } + } + }; +} + +export function engineHomeName(engine: "codex" | "grok"): string { + return engine === "codex" ? ".codex" : ".grok"; +} + +export function engineAuthFile(_engine: "codex" | "grok", engineHomePath: string): string { + return path.join(engineHomePath, "auth.json"); +} + +async function resolveExecutable(agentId: string, engine: "codex" | "grok" | "agy"): Promise { + const sourcePath = process.env.PATH ?? ""; + for (const directory of sourcePath.split(path.delimiter)) { + if (!directory || !path.isAbsolute(directory)) continue; + try { + const canonicalDirectory = await realpath(directory); + const directoryEntry = await lstat(canonicalDirectory); + if (!directoryEntry.isDirectory() || directoryEntry.isSymbolicLink() || (directoryEntry.mode & 0o022) !== 0) continue; + const candidate = path.join(canonicalDirectory, engine); + const entry = await lstat(candidate); + if (!entry.isFile() && !entry.isSymbolicLink()) continue; + const canonical = await realpath(candidate); + const resolved = await lstat(canonical); + if (!resolved.isFile() || resolved.isSymbolicLink() || (resolved.mode & 0o111) === 0) continue; + return canonical; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw unavailable(agentId, engine, "engine executable cannot be inspected"); + } + } + throw unavailable(agentId, engine, "engine executable is unavailable"); +} + +async function probeExecutable(agentId: string, executablePath: string, engine: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executablePath, ["--version"], { cwd: path.dirname(executablePath), env: { PATH: process.env.PATH ?? path.dirname(executablePath), LANG: "C", LC_ALL: "C" }, stdio: ["ignore", "pipe", "pipe"] }); + let bytes = 0; + const output: Buffer[] = []; + const consume = (chunk: Buffer): void => { bytes += chunk.length; if (bytes > MAX_PROBE_BYTES) child.kill("SIGKILL"); else output.push(chunk); }; + child.stdout?.on("data", consume); child.stderr?.on("data", consume); + const timer = setTimeout(() => child.kill("SIGKILL"), 3_000); + child.once("error", () => { clearTimeout(timer); reject(unavailable(agentId, engine, "engine capability probe failed")); }); + child.once("close", (code) => { + clearTimeout(timer); + if (code === 0 && bytes <= MAX_PROBE_BYTES) resolve(createHash("sha256").update(Buffer.concat(output)).digest("hex")); + else reject(unavailable(agentId, engine, "engine capability probe failed")); + }); + }); +} + +async function verifyEngineAuth( + agentId: string, engine: "codex" | "grok" | "agy", engineHomePath: string, + executablePath: string, runtimeHomePath: string, agyBusAddress?: string +): Promise { + if (engine === "agy") { + await verifyAgySubscriptionEnrollment(agentId, executablePath, runtimeHomePath, agyBusAddress); + return; + } + await verifyPortableEngineAuth(agentId, engine, engineHomePath); +} + +async function verifyPortableEngineAuth(agentId: string, engine: "codex" | "grok", engineHomePath: string): Promise { + const authPath = engineAuthFile(engine, engineHomePath); + let handle: Awaited> | undefined; + try { + const home = await lstat(engineHomePath); + if (!home.isDirectory() || home.isSymbolicLink() || home.uid !== process.getuid?.() || (home.mode & 0o777) !== 0o700) { + throw new Error("unsafe engine home"); + } + const entry = await lstat(authPath); + if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 0o777) !== 0o600 || entry.size > MAX_AUTH_BYTES) { + throw new Error("unsafe auth artifact"); + } + handle = await open(authPath, constants.O_RDONLY | noFollow()); + const opened = await handle.stat(); + if (!sameIdentity(identity(entry), identity(opened)) || opened.size > MAX_AUTH_BYTES) throw new Error("auth artifact changed"); + const buffer = Buffer.alloc(Number(opened.size)); + await handle.read(buffer, 0, buffer.length, 0); + const value: unknown = JSON.parse(buffer.toString("utf8")); + const tokens = tokenPair(value); + if (tokens.access === undefined || tokens.refresh === undefined) throw new Error("unsupported auth artifact"); + const expiry = expiryAt(value); + if (expiry !== undefined && expiry <= Date.now() && !tokens.refresh) throw new Error("expired auth artifact"); + } catch { + throw unavailable(agentId, engine, "subscription authentication is not ready; provision the supported local credential and retry"); + } finally { + await handle?.close().catch(() => undefined); + } +} + +export async function verifyAgySubscriptionEnrollment( + agentId: string, + executablePath: string, + runtimeHomePath: string, + busAddress: string | undefined +): Promise { + if (busAddress === undefined) throw unavailable(agentId, "agy", "Daimon subscription realm is unavailable"); + await new Promise((resolve, reject) => { + const child = spawn(executablePath, ["models"], { + cwd: runtimeHomePath, + env: cliChildEnvironment([], runtimeHomePath, { + dbusSessionBusAddress: busAddress, + engine: "agy", + executablePath, + engineHomePath: runtimeHomePath + }), + stdio: ["ignore", "ignore", "ignore"] + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 10_000); + child.once("error", () => { clearTimeout(timer); reject(unavailable(agentId, "agy", "native secure-storage authentication probe failed")); }); + child.once("close", (code) => { + clearTimeout(timer); + if (code === 0) resolve(); + else reject(unavailable(agentId, "agy", "subscription enrollment is required; run the Daimon AGY bootstrap command")); + }); + }); +} +function tokenPair(value: unknown): { access?: string; refresh?: string } { + const source = object(value); + const nested = object(source?.tokens) ?? source; + return { + access: firstString(nested, ["access_token", "accessToken", "token"]), + refresh: firstString(nested, ["refresh_token", "refreshToken"]) + }; +} + +function expiryAt(value: unknown): number | undefined { + const candidate = firstNumber(object(value), ["expires_at", "expiresAt", "expiry"]) + ?? firstNumber(object(object(value)?.tokens), ["expires_at", "expiresAt", "expiry"]); + if (candidate === undefined) return undefined; + return candidate < 10_000_000_000 ? candidate * 1_000 : candidate; +} + +function object(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} +function firstString(value: Record | undefined, names: readonly string[]): string | undefined { + for (const name of names) if (typeof value?.[name] === "string" && value[name]!.length > 0) return value[name] as string; + return undefined; +} +function firstNumber(value: Record | undefined, names: readonly string[]): number | undefined { + for (const name of names) if (typeof value?.[name] === "number" && Number.isFinite(value[name])) return value[name] as number; + return undefined; +} +function noFollow(): number { return (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; } +function identity(value: Awaited>): FileIdentity { + const numeric = value as typeof value & { dev: number; ino: number; size: number; mtimeMs: number; mode: number }; + return { dev: numeric.dev, ino: numeric.ino, size: numeric.size, mtimeMs: numeric.mtimeMs, mode: numeric.mode & 0o7777 }; +} +function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs && left.mode === right.mode; } +function unavailable(agentId: string, engine: string, detail: string): Error { return new Error(`agent ${agentId} ${engine} is unavailable: ${detail}`); } diff --git a/src/runtime/index.ts b/src/runtime/index.ts new file mode 100644 index 0000000..3111163 --- /dev/null +++ b/src/runtime/index.ts @@ -0,0 +1,40 @@ +export * from "./organizationRuntime.js"; +export * from "./contractManifest.js"; +export * from "./agySubscriptionRealm.js"; +export { createOrganizationRuntimeHost } from "./organizationRuntimeHost.js"; +export { createOrganizationRuntimeControlHost } from "./organizationRuntimeControl.js"; +export { WakeTransitionLockBlockedError } from "./wakeAcceptanceStore.js"; +export { + OFFLINE_RECONCILIATION_BLOCKED_CODE, + OFFLINE_RECONCILIATION_REQUEST_SCHEMA, + OFFLINE_RECONCILIATION_VERSION, + assertOfflineReconciliationLeaseAvailable, + OfflineTransitionReconciliationBlockedError, + parseOfflineTransitionReconciliationRequest, + reconcileOfflineWakeTransition, + type OfflineDeploymentAttestation, + type OfflineTransitionReconciliationAuthorizationContext, + type OfflineTransitionReconciliationOptions, + type OfflineTransitionReconciliationProofReceipt, + type OfflineTransitionReconciliationReceipt, + type OfflineTransitionReconciliationRequest, + type OfflineTransitionReconciliationResult +} from "./wakeAcceptanceReconciliation.js"; +export { + MAX_WAKE_ACCEPTANCE_BYTES, + MAX_WAKE_ACCEPTANCE_RECORD_BYTES, + WAKE_ACCEPTANCE_VERSION, + WAKE_ACCEPTANCE_REQUEST_SCHEMA, + WAKE_RECEIPT_STATUS_VERSION, + WAKE_RECEIPT_STATUS_SCHEMA, + WAKE_V2_VERSION, + parseWakeAcceptanceRequest, + wakeAcceptanceDigest, + type OrganizationRuntimeWakeAcceptance, + type OrganizationRuntimeWakeAcceptanceRequest, + type OrganizationRuntimeWakeAcceptanceResult, + type OrganizationRuntimeWakeReceiptStatus, + type WakeReceiptCode, + type WakeReceiptState +} from "./wakeAcceptanceTypes.js"; +export type { OrganizationRuntimeControlHost, OrganizationRuntimeControlOptions } from "./organizationRuntimeControl.js"; diff --git a/src/runtime/organizationRuntime.test.ts b/src/runtime/organizationRuntime.test.ts new file mode 100644 index 0000000..504a320 --- /dev/null +++ b/src/runtime/organizationRuntime.test.ts @@ -0,0 +1,196 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; + +import { + ORGANIZATION_RUNTIME_CONFIG_SCHEMA, + ORGANIZATION_RUNTIME_MAX_AGENTS, + ORGANIZATION_RUNTIME_MAX_STRING_BYTES, + ORGANIZATION_RUNTIME_VERSION, + validateOrganizationRuntimeConfig, + parseOrganizationRuntimeConfig, + parseOrganizationRuntimeWakeRequest +} from "./organizationRuntime.js"; +import type { OrganizationRuntimeEngineIntent } from "./organizationRuntime.js"; + +const valid = () => ({ + version: ORGANIZATION_RUNTIME_VERSION, + host: { bindHost: "127.0.0.1", port: 4318, controlTokenEnv: "DAIMON_CONTROL_TOKEN" }, + agents: [{ + id: "editor", + name: "Editor", + instructions: "Write a concise report.", + workspacePath: "/runtime/workspaces/editor", + runtimeHomePath: "/runtime/homes/editor", + engine: { kind: "codex" } as OrganizationRuntimeEngineIntent + }] +}); + +test("parses the strict flat v1 contract", () => { + const parsed = parseOrganizationRuntimeConfig(valid()); + assert.equal(parsed.version, ORGANIZATION_RUNTIME_VERSION); + assert.equal(parsed.agents[0]?.engine.kind, "codex"); + assert.equal(ORGANIZATION_RUNTIME_CONFIG_SCHEMA.additionalProperties, false); + assert.equal(ORGANIZATION_RUNTIME_CONFIG_SCHEMA.properties.agents.minItems, 1); +}); + +test("semantic validator and parser share a conformance corpus", () => { + const cases: readonly [unknown, boolean][] = [ + [valid(), true], + [{ ...valid(), agents: [] }, false], + [{ ...valid(), agents: [{ ...valid().agents[0], id: " " }] }, false], + [{ ...valid(), agents: [{ ...valid().agents[0], workspacePath: "/work/../escape" }] }, true], + [{ ...valid(), agents: [{ ...valid().agents[0], runtimeHomePath: "/home//agent" }] }, true], + [{ ...valid(), agents: [{ ...valid().agents[0], engine: { kind: "pi" } }] }, false], + [{ ...valid(), agents: [{ ...valid().agents[0], engine: { kind: "scripted", protocol: "noopolis.daimon.scripted.v1", programPath: "agent.mjs" } }] }, false], + [{ ...valid(), agents: [valid().agents[0], { ...valid().agents[0] }] }, false] + ]; + for (const [candidate, accepted] of cases) { + assert.equal(validateOrganizationRuntimeConfig(candidate), accepted); + assert.equal(accepted, (() => { try { parseOrganizationRuntimeConfig(candidate); return true; } catch { return false; } })()); + } +}); + +test("rejects sparse agent arrays", () => { + const sparse = valid(); + sparse.agents = new Array(1) as typeof sparse.agents; + assert.throws(() => parseOrganizationRuntimeConfig(sparse), /present record/); + const mixed = valid(); + mixed.agents = [{ ...mixed.agents[0]! }, , { ...mixed.agents[0]!, id: "other" }] as typeof mixed.agents; + assert.throws(() => parseOrganizationRuntimeConfig(mixed), /present record/); +}); + +test("bounds programmatic config size, agents, and every string field", () => { + const tooMany = valid(); + tooMany.agents = Array.from({ length: ORGANIZATION_RUNTIME_MAX_AGENTS + 1 }, (_, index) => ({ + ...valid().agents[0]!, id: `agent-${index}` + })); + assert.throws(() => parseOrganizationRuntimeConfig(tooMany), /between 1 and/); + const tooLong = valid(); + tooLong.agents[0]!.instructions = "x".repeat(ORGANIZATION_RUNTIME_MAX_STRING_BYTES + 1); + assert.throws(() => parseOrganizationRuntimeConfig(tooLong), /string limit/); + assert.equal(ORGANIZATION_RUNTIME_CONFIG_SCHEMA.properties.agents.maxItems, ORGANIZATION_RUNTIME_MAX_AGENTS); +}); + +test("rejects unknown versions and organization semantics before any side effect", () => { + const unknownVersion = valid(); + unknownVersion.version = "noopolis.daimon.organization-runtime.v2" as typeof ORGANIZATION_RUNTIME_VERSION; + assert.throws(() => parseOrganizationRuntimeConfig(unknownVersion), /config.version/); + for (const forbidden of ["teams", "roles", "parents", "members", "edges", "schedules", "wakePolicy", "deployment", "moltnet"]) { + const config = valid() as Record; + config[forbidden] = []; + assert.throws(() => parseOrganizationRuntimeConfig(config), /exactly/); + } +}); + +test("rejects unsafe auth names, duplicate ids, and invalid absolute paths", () => { + const unsafe = valid(); + unsafe.host.controlTokenEnv = "TOKEN; rm"; + assert.throws(() => parseOrganizationRuntimeConfig(unsafe), /safe environment/); + const duplicate = valid(); + duplicate.agents.push({ ...duplicate.agents[0]!, id: "editor" }); + assert.throws(() => parseOrganizationRuntimeConfig(duplicate), /duplicate id/); + const relative = valid(); + relative.agents[0]!.workspacePath = "workspaces/editor"; + assert.throws(() => parseOrganizationRuntimeConfig(relative), /absolute POSIX/); +}); + +test("rejects inherited records and every lexical agent-path collision", () => { + const inherited = Object.create({ version: ORGANIZATION_RUNTIME_VERSION, host: valid().host, agents: valid().agents }); + assert.throws(() => parseOrganizationRuntimeConfig(inherited), /plain own-properties/); + const missingOwn = { version: ORGANIZATION_RUNTIME_VERSION, host: Object.create(valid().host), agents: valid().agents }; + assert.throws(() => parseOrganizationRuntimeConfig(missingOwn), /plain own-properties|exactly/); + for (const [workspacePath, runtimeHomePath] of [ + ["/runtime/workspaces/editor", "/runtime/workspaces/editor"], + ["/runtime/workspaces", "/runtime/workspaces/editor"], + ["/runtime/homes/other", "/runtime/homes"] + ]) { + const config = valid(); + config.agents.push({ ...config.agents[0]!, id: "other", workspacePath, runtimeHomePath }); + assert.throws(() => parseOrganizationRuntimeConfig(config), /must not overlap/); + } +}); + +test("rejects same-agent overlap and filesystem-root capabilities", () => { + for (const [workspacePath, runtimeHomePath] of [ + ["/runtime/agent", "/runtime/agent"], + ["/runtime/agent", "/runtime/agent/home"], + ["/runtime/agent/workspace", "/runtime/agent"] + ]) { + const config = valid(); + config.agents[0]!.workspacePath = workspacePath; + config.agents[0]!.runtimeHomePath = runtimeHomePath; + assert.throws(() => parseOrganizationRuntimeConfig(config), /must not overlap/); + } + for (const key of ["workspacePath", "runtimeHomePath"] as const) { + const config = valid(); + config.agents[0]![key] = "/"; + assert.throws(() => parseOrganizationRuntimeConfig(config), /filesystem root/); + } +}); + +test("strict config and wake parsers never invoke getters or accept hidden keys", () => { + let reads = 0; + const hostile = valid() as Record; + Object.defineProperty(hostile, "version", { enumerable: true, get() { reads += 1; return ORGANIZATION_RUNTIME_VERSION; } }); + assert.throws(() => parseOrganizationRuntimeConfig(hostile), /enumerable data property/); + assert.equal(reads, 0); + + const hidden = valid() as Record; + Object.defineProperty(hidden, "argv", { enumerable: false, value: ["--unsafe"] }); + assert.throws(() => parseOrganizationRuntimeConfig(hidden), /enumerable data property/); + const symbols = valid() as Record; + symbols[Symbol("argv")] = ["--unsafe"]; + assert.throws(() => parseOrganizationRuntimeConfig(symbols), /symbol properties/); + + const wake = { token: "token", agentId: "agent", event: { + version: "noopolis.daimon.wake.v1", id: "wake", kind: "manual", text: "go", occurredAt: "2026-08-17T12:00:00.000Z" + } } as Record; + Object.defineProperty(wake, "agentId", { enumerable: true, get() { reads += 1; return "agent"; } }); + assert.throws(() => parseOrganizationRuntimeWakeRequest(wake), /enumerable data property/); + assert.equal(reads, 0); +}); + +test("canonicalizes POSIX absolute paths before storing and comparing isolation", () => { + const canonical = valid(); + canonical.agents[0]!.workspacePath = "/runtime/./workspaces/editor/"; + canonical.agents[0]!.runtimeHomePath = "/runtime/homes/../homes/editor//"; + const parsed = parseOrganizationRuntimeConfig(canonical); + assert.equal(parsed.agents[0]?.workspacePath, "/runtime/workspaces/editor"); + assert.equal(parsed.agents[0]?.runtimeHomePath, "/runtime/homes/editor"); + for (const [firstWorkspacePath, secondWorkspacePath] of [ + ["/runtime/shared/", "/runtime/shared/child"], + ["/", "/runtime/child"], + ["/runtime/a/../shared", "/runtime/shared/child/./leaf"] + ]) { + const config = valid(); + config.agents[0]!.workspacePath = firstWorkspacePath; + config.agents[0]!.runtimeHomePath = "/runtime/homes/first"; + config.agents.push({ ...config.agents[0]!, id: "other", workspacePath: secondWorkspacePath, runtimeHomePath: "/runtime/homes/second" }); + assert.throws(() => parseOrganizationRuntimeConfig(config), /must not overlap/); + } + const windowsSeparators = valid(); + windowsSeparators.agents[0]!.workspacePath = "\\runtime\\workspace"; + assert.throws(() => parseOrganizationRuntimeConfig(windowsSeparators), /POSIX/); +}); + +test("accepts only Daimon's three production engines", () => { + for (const kind of ["codex", "grok", "agy"] as const) { + const candidate = valid(); + candidate.agents[0]!.engine = { kind }; + assert.equal(parseOrganizationRuntimeConfig(candidate).agents[0]?.engine.kind, kind); + } + const injected = valid(); + injected.agents[0]!.engine = { kind: "codex", argv: ["--unsafe"] } as never; + assert.throws(() => parseOrganizationRuntimeConfig(injected), /exactly/); +}); + +test("wake-result status and code pairs are fixed", () => { + const fixtures = [ + { version: "noopolis.daimon.wake-result.v1", status: "completed", agentId: "a", wakeId: "w", text: "done", durationMs: 1 }, + { version: "noopolis.daimon.wake-result.v1", status: "rejected", agentId: "a", wakeId: "w", code: "unauthorized" }, + { version: "noopolis.daimon.wake-result.v1", status: "rejected", agentId: "a", wakeId: "w", code: "queue_full" }, + { version: "noopolis.daimon.wake-result.v1", status: "stopped", agentId: "a", wakeId: "w", code: "queued_wake_stopped" }, + { version: "noopolis.daimon.wake-result.v1", status: "failed", agentId: "a", wakeId: "w", code: "engine_failed" } + ] as const; + assert.deepEqual(fixtures.map((result) => result.status), ["completed", "rejected", "rejected", "stopped", "failed"]); +}); diff --git a/src/runtime/organizationRuntime.ts b/src/runtime/organizationRuntime.ts new file mode 100644 index 0000000..494b251 --- /dev/null +++ b/src/runtime/organizationRuntime.ts @@ -0,0 +1,69 @@ +/** The only configuration version accepted by the v1 runtime contract. */ +export const ORGANIZATION_RUNTIME_VERSION = "noopolis.daimon.organization-runtime.v1" as const; +export const ORGANIZATION_RUNTIME_MAX_AGENTS = 32; +export const ORGANIZATION_RUNTIME_MAX_CONFIG_BYTES = 1_048_576; +export const ORGANIZATION_RUNTIME_MAX_STRING_BYTES = 16_384; +export const ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS = 4_096; +export const ORGANIZATION_RUNTIME_MAX_WAKE_TEXT_BYTES = 16_384; + +/** Machine-readable companion schema for config producers and validators. */ +export const ORGANIZATION_RUNTIME_CONFIG_SCHEMA = { + $schema: "https://json-schema.org/draft/2020-12/schema", $id: ORGANIZATION_RUNTIME_VERSION, type: "object", additionalProperties: false, + required: ["version", "host", "agents"], + properties: { + version: { const: ORGANIZATION_RUNTIME_VERSION }, + host: { type: "object", additionalProperties: false, required: ["bindHost", "port", "controlTokenEnv"], properties: { + bindHost: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + port: { type: "integer", minimum: 1, maximum: 65_535 }, + controlTokenEnv: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "^[A-Za-z_][A-Za-z0-9_]*$" } + } }, + agents: { type: "array", minItems: 1, maxItems: ORGANIZATION_RUNTIME_MAX_AGENTS, items: { + type: "object", additionalProperties: false, required: ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine"], properties: { + id: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + name: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + instructions: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + workspacePath: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "^/" }, + runtimeHomePath: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "^/" }, + engine: { type: "object", additionalProperties: false, required: ["kind"], properties: { + kind: { enum: ["codex", "grok", "agy"] } + } } + } + } } + } +} as const; + +export type OrganizationRuntimeEngineKind = "codex" | "grok" | "agy"; +export type OrganizationRuntimeEngineIntent = Readonly<{ kind: OrganizationRuntimeEngineKind }>; +export type OrganizationRuntimeAgentConfig = Readonly<{ id: string; name: string; instructions: string; workspacePath: string; runtimeHomePath: string; engine: OrganizationRuntimeEngineIntent }>; +export type OrganizationRuntimeHostConfig = Readonly<{ bindHost: string; port: number; /** Variable name only; never secret configuration data. */ controlTokenEnv: string }>; +export type OrganizationRuntimeConfig = Readonly<{ version: typeof ORGANIZATION_RUNTIME_VERSION; host: OrganizationRuntimeHostConfig; agents: readonly OrganizationRuntimeAgentConfig[] }>; +export type OrganizationRuntimeLifecycleState = "starting" | "running" | "stopping" | "stopped"; +export type OrganizationRuntimeWakeEvent = Readonly<{ version: "noopolis.daimon.wake.v1"; id: string; kind: "manual" | "message" | "external"; text: string; occurredAt: string }>; +export type OrganizationRuntimeWakeRequest = Readonly<{ token: string | undefined; agentId: string; event: OrganizationRuntimeWakeEvent }>; +export type OrganizationRuntimeWakeResult = + | Readonly<{ version: "noopolis.daimon.wake-result.v1"; status: "completed"; agentId: string; wakeId: string; text: string; durationMs: number }> + | Readonly<{ version: "noopolis.daimon.wake-result.v1"; status: "rejected"; agentId: string; wakeId: string; code: "invalid_request" | "unauthorized" | "unknown_agent" | "queue_full" }> + | Readonly<{ version: "noopolis.daimon.wake-result.v1"; status: "stopped"; agentId: string; wakeId: string; code: "host_stopping" | "host_stopped" | "queued_wake_stopped" | "active_wake_aborted" }> + | Readonly<{ version: "noopolis.daimon.wake-result.v1"; status: "failed"; agentId: string; wakeId: string; code: "engine_failed" }>; +export type OrganizationRuntimeAgentHealth = Readonly<{ agentId: string; state: OrganizationRuntimeLifecycleState | "idle" | "running" | "failed" }>; +export type OrganizationRuntimeHealth = Readonly<{ version: "noopolis.daimon.organization-runtime-health.v1"; state: OrganizationRuntimeLifecycleState; agents: readonly OrganizationRuntimeAgentHealth[] }>; +export type OrganizationRuntimeActivityRequest = Readonly<{ agentId?: string; cursor?: string; limit: number }>; +export type OrganizationRuntimeActivity = Readonly<{ id: string; agentId: string; wakeId?: string; kind: "wake_started" | "wake_completed" | "wake_rejected" | "wake_aborted" | "agent_stopped"; occurredAt: string }>; +export type OrganizationRuntimeActivityPage = Readonly<{ version: "noopolis.daimon.organization-runtime-activity.v1"; items: readonly OrganizationRuntimeActivity[]; nextCursor?: string }>; +export type OrganizationRuntimeShutdownCompletion = Readonly<{ version: "noopolis.daimon.organization-runtime-stop.v1"; state: "stopped" }>; + +/** One host runs isolated agents; it never coordinates their work. */ +export interface OrganizationRuntimeHost { + start(): Promise; + wake(request: OrganizationRuntimeWakeRequest): Promise; + health(agentId?: string): Promise; + activity(request: OrganizationRuntimeActivityRequest): Promise; + stop(): Promise; +} + +export { + isOrganizationRuntimeConfig, + parseOrganizationRuntimeConfig, + parseOrganizationRuntimeWakeRequest, + validateOrganizationRuntimeConfig +} from "./organizationRuntimeParsing.js"; diff --git a/src/runtime/organizationRuntime.types.test.ts b/src/runtime/organizationRuntime.types.test.ts new file mode 100644 index 0000000..79f995b --- /dev/null +++ b/src/runtime/organizationRuntime.types.test.ts @@ -0,0 +1,79 @@ +import type { + OrganizationRuntimeActivityPage, + OrganizationRuntimeActivity, + OrganizationRuntimeActivityRequest, + OrganizationRuntimeAgentHealth, + OrganizationRuntimeHealth, + OrganizationRuntimeHost, + OrganizationRuntimeShutdownCompletion, + OrganizationRuntimeWakeRequest, + OrganizationRuntimeWakeEvent, + OrganizationRuntimeWakeResult +} from "./organizationRuntime.js"; +import type { OrganizationRuntimeConfig } from "./organizationRuntime.js"; + +type Assert = T; +type IsEqual = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? + ((() => T extends B ? 1 : 2) extends (() => T extends A ? 1 : 2) ? true : false) : false; + +type StartSignature = Assert Promise>>; +type WakeSignature = Assert Promise>>; +type HealthSignature = Assert Promise>>; +type ActivitySignature = Assert Promise>>; +type StopSignature = Assert Promise>>; + +const start: StartSignature = true; +const wake: WakeSignature = true; +const health: HealthSignature = true; +const activity: ActivitySignature = true; +const stop: StopSignature = true; +void [start, wake, health, activity, stop]; + +function configIsReadonly(config: OrganizationRuntimeConfig): void { + // @ts-expect-error configuration is immutable at every public level. + config.host.port = 9; + // @ts-expect-error agent records are immutable. + config.agents[0].name = "different"; +} +void configIsReadonly; + +function allPublicPayloadsAreReadonly( + event: OrganizationRuntimeWakeEvent, + request: OrganizationRuntimeWakeRequest, + agentHealth: OrganizationRuntimeAgentHealth, + healthValue: OrganizationRuntimeHealth, + activityValue: OrganizationRuntimeActivity, + activityPage: OrganizationRuntimeActivityPage, + shutdown: OrganizationRuntimeShutdownCompletion +): void { + // @ts-expect-error + event.text = "x"; + // @ts-expect-error + request.agentId = "x"; + // @ts-expect-error + agentHealth.state = "failed"; + // @ts-expect-error + healthValue.agents[0].state = "failed"; + // @ts-expect-error + activityValue.kind = "agent_stopped"; + // @ts-expect-error + activityPage.items[0].id = "x"; + // @ts-expect-error + shutdown.state = "stopped"; +} +void allPublicPayloadsAreReadonly; + +function exhaustiveWakeResult(result: OrganizationRuntimeWakeResult): string { + switch (result.status) { + case "completed": return result.text; + case "rejected": return result.code; + case "stopped": return result.code; + case "failed": return result.code; + default: { + const impossible: never = result; + return impossible; + } + } +} +void exhaustiveWakeResult; diff --git a/src/runtime/organizationRuntimeControl.ts b/src/runtime/organizationRuntimeControl.ts new file mode 100644 index 0000000..3fc073f --- /dev/null +++ b/src/runtime/organizationRuntimeControl.ts @@ -0,0 +1,133 @@ +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; + +import { + parseOrganizationRuntimeConfig, + type OrganizationRuntimeConfig, + type OrganizationRuntimeHost, + type OrganizationRuntimeShutdownCompletion, + type OrganizationRuntimeWakeRequest +} from "./organizationRuntime.js"; +import { createOrganizationRuntimeHost } from "./organizationRuntimeHost.js"; +import { WakeAcceptanceConflictError, WakeAcceptanceStore, WakeExecutionClaimLostError, publicAcceptance, type WakeAcceptanceStoreTestOptions } from "./wakeAcceptanceStore.js"; +import { + parseWakeAcceptanceRequest, + type OrganizationRuntimeWakeAcceptanceRequest, + type OrganizationRuntimeWakeAcceptanceResult, + type OrganizationRuntimeWakeReceiptStatus +} from "./wakeAcceptanceTypes.js"; + +export type OrganizationRuntimeControlHost = OrganizationRuntimeHost & Readonly<{ + accept(request: unknown): Promise; + wakeReceipt(token: string | undefined, acceptanceId: string): Promise; +}>; +export type OrganizationRuntimeControlOptions = Readonly<{ acceptanceStorePath: string; controlToken?: string }>; +type TestControlOptions = OrganizationRuntimeControlOptions & Readonly<{ storeOptions?: WakeAcceptanceStoreTestOptions }>; +type CoreHost = OrganizationRuntimeHost; +type AcceptanceRecord = Awaited>["record"]; + +/** + * The v2 control facade persists acceptance before delegating a turn to the + * unchanged v1 host. It owns no scheduling, message routing, or engine logic. + */ +export function createOrganizationRuntimeControlHost(config: unknown, options: OrganizationRuntimeControlOptions): OrganizationRuntimeControlHost { + const parsed = parseOrganizationRuntimeConfig(config); + return createControl(parsed, createOrganizationRuntimeHost(parsed), options); +} + +/** @internal Test seam; intentionally absent from the public runtime barrel. */ +export function createOrganizationRuntimeControlHostWithCoreForTest(config: unknown, host: CoreHost, options: TestControlOptions): OrganizationRuntimeControlHost { + return createControl(parseOrganizationRuntimeConfig(config), host, options, options.storeOptions); +} + +function createControl(config: OrganizationRuntimeConfig, host: CoreHost, options: OrganizationRuntimeControlOptions, storeOptions?: WakeAcceptanceStoreTestOptions): OrganizationRuntimeControlHost { + const expectedToken = options.controlToken ?? process.env[config.host.controlTokenEnv]; + const knownAgents = new Set(config.agents.map((agent) => agent.id)); + const ownerId = randomUUID(); + const inFlight = new Map>(); + let store: WakeAcceptanceStore | undefined; + let started = false; + let stopping = false; + + const dispatch = (record: AcceptanceRecord): void => { + if (inFlight.has(record.acceptance_id) || store === undefined) return; + const activeStore = store; + const work = (async () => { + const claim = await activeStore.acquireClaim(record.acceptance_id, ownerId); + if (claim.state !== "acquired") return; + const current = await activeStore.transitionClaimed(record.acceptance_id, claim.claim, "running"); + if (current.state !== "running") return; + const request: OrganizationRuntimeWakeRequest = { + token: expectedToken, + agentId: current.agent_id, + event: { version: "noopolis.daimon.wake.v1", id: current.delivery_id, kind: current.event.kind, text: current.event.text, occurredAt: current.event.occurred_at } + }; + const result = await host.wake(request); + if (result.status === "completed") await activeStore.transitionClaimed(current.acceptance_id, claim.claim, "completed"); + else if (result.status === "failed") await activeStore.transitionClaimed(current.acceptance_id, claim.claim, "failed", "engine_failed"); + else if (result.status === "stopped") await activeStore.transitionClaimed(current.acceptance_id, claim.claim, "stopped", result.code === "host_stopped" ? "host_stopped" : "host_stopping"); + else await activeStore.transitionClaimed(current.acceptance_id, claim.claim, "failed", result.code === "queue_full" ? "queue_full" : result.code === "unknown_agent" ? "unknown_agent" : "host_stopped"); + })().catch(async (error: unknown) => { + if (error instanceof WakeExecutionClaimLostError) return; + const claim = await activeStore.acquireClaim(record.acceptance_id, ownerId).catch(() => undefined); + if (claim?.state === "acquired") await activeStore.transitionClaimed(record.acceptance_id, claim.claim, "failed", "engine_failed").catch(() => undefined); + }).finally(() => { inFlight.delete(record.acceptance_id); }); + inFlight.set(record.acceptance_id, work); + }; + + return { + wake: async (request) => await host.wake(request), + health: async (agentId) => await host.health(agentId), + activity: async (request) => await host.activity(request), + async start(): Promise { + if (started) return; + if (stopping) throw new Error("organization runtime control host has been stopped"); + if (expectedToken === undefined || !expectedToken.trim()) throw new Error("required control token is missing or blank"); + const opened = await WakeAcceptanceStore.open(options.acceptanceStorePath, storeOptions); + try { + await host.start(); + store = opened; + started = true; + for (const record of await opened.recoverable(knownAgents)) dispatch(record); + } catch (error) { + await host.stop().catch(() => undefined); + await opened.close().catch(() => undefined); + throw error; + } + }, + async accept(value: unknown): Promise { + let request: OrganizationRuntimeWakeAcceptanceRequest; + try { request = parseWakeAcceptanceRequest(value); } catch { return { version: "noopolis.daimon.wake-acceptance.v2", state: "rejected", code: "invalid_request" }; } + if (!tokensEqual(expectedToken, request.token)) return { version: "noopolis.daimon.wake-acceptance.v2", state: "rejected", code: "unauthorized" }; + if (!started || stopping) return { version: "noopolis.daimon.wake-acceptance.v2", state: "stopped", code: stopping ? "host_stopping" : "host_stopped" }; + if (!knownAgents.has(request.agent_id)) return { version: "noopolis.daimon.wake-acceptance.v2", state: "rejected", code: "unknown_agent" }; + try { + const accepted = await store!.accept(request); + dispatch(accepted.record); + return publicAcceptance(accepted.record); + } catch (error) { + if (error instanceof WakeAcceptanceConflictError) return { version: "noopolis.daimon.wake-acceptance.v2", state: "rejected", code: "delivery_conflict" }; + throw error; + } + }, + async wakeReceipt(token: string | undefined, acceptanceId: string): Promise { + if (!tokensEqual(expectedToken, token) || store === undefined) return undefined; + return await store.status(acceptanceId); + }, + async stop(): Promise { + stopping = true; + const result = await host.stop(); + await Promise.allSettled(inFlight.values()); + await store?.releaseClaims(ownerId); + await store?.close(); + store = undefined; + started = false; + return result; + } + }; +} + +function tokensEqual(expected: string | undefined, actual: string | undefined): boolean { + if (expected === undefined || !expected.trim()) return false; + const digest = (value: string): Buffer => createHash("sha256").update(value).digest(); + return timingSafeEqual(digest(expected), digest(actual ?? "")); +} diff --git a/src/runtime/organizationRuntimeHost.test.ts b/src/runtime/organizationRuntimeHost.test.ts new file mode 100644 index 0000000..6888f40 --- /dev/null +++ b/src/runtime/organizationRuntimeHost.test.ts @@ -0,0 +1,294 @@ +import { strict as assert } from "node:assert"; +import { chmod, mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import type { AgentHandle, AgentStatus, WakeEvent, WakeResult } from "../core/types.js"; +import { ORGANIZATION_RUNTIME_VERSION, type OrganizationRuntimeWakeRequest } from "./organizationRuntime.js"; +import { createOrganizationRuntimeHostForTest } from "./organizationRuntimeHost.js"; +import { prepareOrganizationRuntimePaths } from "./physicalReadiness.js"; + +const tokenEnv = "DAIMON_RUNTIME_HOST_TEST_TOKEN"; + +const config = (ids: readonly string[] = ["alpha", "beta"]) => ({ + version: ORGANIZATION_RUNTIME_VERSION, + host: { bindHost: "127.0.0.1", port: 4318, controlTokenEnv: tokenEnv }, + agents: ids.map((id) => ({ + id, + name: id, + instructions: `Act as ${id}.`, + workspacePath: `/runtime/workspaces/${id}`, + runtimeHomePath: `/runtime/homes/${id}`, + engine: { kind: "codex" as const } + })) +}); + +const wake = (agentId: string, id: string, token = "test-token"): OrganizationRuntimeWakeRequest => ({ + token, + agentId, + event: { version: "noopolis.daimon.wake.v1", id, kind: "manual", text: id, occurredAt: "2026-08-17T00:00:00.000Z" } +}); + +class FakeHandle implements AgentHandle { + readonly seen: string[] = []; + readonly stopped: Promise; + private resolveStop!: () => void; + private state: AgentStatus["state"] = "idle"; + private readonly waiting = new Map void>(); + + public constructor(private readonly hold = false) { + this.stopped = new Promise((resolve) => { this.resolveStop = resolve; }); + } + + async wake(event: WakeEvent): Promise { + this.state = "running"; + this.seen.push(event.id); + if (this.hold) await new Promise((resolve) => this.waiting.set(event.id, resolve)); + this.state = "idle"; + return { agentId: this.id, text: `done:${event.id}`, durationMs: 1 }; + } + + get id(): string { return "fake"; } + status(): AgentStatus { return { agentId: this.id, state: this.state }; } + async stop(): Promise { + this.state = "stopped"; + for (const release of this.waiting.values()) release(); + this.resolveStop(); + } + release(id: string): void { this.waiting.get(id)?.(); } +} + +test("requires a non-blank control token before constructing any agent", async () => { + delete process.env[tokenEnv]; + let calls = 0; + const host = createOrganizationRuntimeHostForTest(config(), async () => { + calls += 1; + return new FakeHandle(); + }); + await assert.rejects(host.start(), /missing or blank/); + assert.equal(calls, 0); + assert.deepEqual((await host.health()).agents.map((agent) => agent.state), ["stopped", "stopped"]); +}); + +test("caller-root preflight rejects missing, unsafe, linked, and overlapping roots before agents", async () => { + process.env[tokenEnv] = "test-token"; + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-host-roots-")); + const workspace = path.join(root, "workspace"); + const home = path.join(root, "home"); + const candidate = config(["alpha"]); + candidate.agents[0]!.workspacePath = workspace; + candidate.agents[0]!.runtimeHomePath = home; + let calls = 0; + const host = (): ReturnType => createOrganizationRuntimeHostForTest( + candidate, + async () => { calls += 1; return new FakeHandle(); }, + () => prepareOrganizationRuntimePaths(candidate.agents) + ); + try { + await mkdir(home, { mode: 0o700 }); + await assert.rejects(host().start(), /ENOENT/); + assert.equal(calls, 0); + await mkdir(workspace, { mode: 0o700 }); + await chmod(home, 0o755); + await assert.rejects(host().start(), /mode 0700/); + assert.equal(calls, 0); + await chmod(home, 0o700); + const linked = path.join(root, "linked"); + await symlink(workspace, linked); + candidate.agents[0]!.workspacePath = linked; + await assert.rejects(host().start(), /symlink/); + assert.equal(calls, 0); + candidate.agents[0]!.workspacePath = workspace; + candidate.agents[0]!.runtimeHomePath = workspace; + assert.throws(() => host(), /must not overlap/); + assert.equal(calls, 0); + } finally { + delete process.env[tokenEnv]; + await rm(root, { recursive: true, force: true }); + } +}); + +test("validates malformed wakes before activity and authenticates before stopped-state routing", async () => { + process.env[tokenEnv] = "test-token"; + const host = createOrganizationRuntimeHostForTest(config(["alpha"]), async () => new FakeHandle()); + const invalid = await host.wake({ token: "test-token", agentId: "alpha", event: { id: "bad" } } as never); + assert.deepEqual(invalid, { + version: "noopolis.daimon.wake-result.v1", status: "rejected", agentId: "", wakeId: "", code: "invalid_request" + }); + assert.equal((await host.activity({ limit: 10 })).items.length, 0); + assert.equal((await host.wake(wake("alpha", "unstarted", "wrong"))).status, "rejected"); + assert.equal((await host.wake(wake("not-present", "unstarted"))).status, "stopped"); + assert.equal((await host.wake(wake("alpha", "unstarted"))).status, "stopped"); + delete process.env[tokenEnv]; +}); + +test("rolls back every constructed agent when atomic startup fails", async () => { + process.env[tokenEnv] = "test-token"; + const started: FakeHandle[] = []; + const host = createOrganizationRuntimeHostForTest(config(), async (agent) => { + if (agent.id === "beta") throw new Error("cannot start beta"); + const handle = new FakeHandle(); + started.push(handle); + return handle; + }); + await assert.rejects(host.start(), /cannot start beta/); + await Promise.all(started.map((handle) => handle.stopped)); + assert.equal((await host.health()).state, "stopped"); + delete process.env[tokenEnv]; +}); + +test("serializes one agent while allowing other agents to run independently", async () => { + process.env[tokenEnv] = "test-token"; + const handles = new Map(); + const host = createOrganizationRuntimeHostForTest(config(), async (agent) => { + const handle = new FakeHandle(true); + handles.set(agent.id, handle); + return handle; + }); + await host.start(); + const alphaOne = host.wake(wake("alpha", "alpha-1")); + const alphaTwo = host.wake(wake("alpha", "alpha-2")); + const betaOne = host.wake(wake("beta", "beta-1")); + await waitFor(() => handles.get("alpha")?.seen.length === 1 && handles.get("beta")?.seen.length === 1); + assert.deepEqual(handles.get("alpha")?.seen, ["alpha-1"]); + assert.deepEqual(handles.get("beta")?.seen, ["beta-1"]); + handles.get("beta")?.release("beta-1"); + handles.get("alpha")?.release("alpha-1"); + await waitFor(() => handles.get("alpha")?.seen.length === 2); + handles.get("alpha")?.release("alpha-2"); + assert.deepEqual((await alphaOne).status, "completed"); + assert.deepEqual((await alphaTwo).status, "completed"); + assert.deepEqual((await betaOne).status, "completed"); + await host.stop(); + delete process.env[tokenEnv]; +}); + +test("authenticates targeted wakes and exposes bounded activity", async () => { + process.env[tokenEnv] = "test-token"; + const host = createOrganizationRuntimeHostForTest(config(["alpha"]), async () => new FakeHandle()); + await host.start(); + assert.deepEqual(await host.wake(wake("alpha", "bad", "wrong")), { + version: "noopolis.daimon.wake-result.v1", status: "rejected", agentId: "alpha", wakeId: "bad", code: "unauthorized" + }); + assert.equal((await host.wake(wake("none", "unknown"))).status, "rejected"); + assert.equal((await host.wake(wake("alpha", "good"))).status, "completed"); + const page = await host.activity({ agentId: "alpha", limit: 2 }); + assert.equal(page.items.length, 2); + await assert.rejects(host.activity({ limit: 0 }), /between 1 and 100/); + await assert.rejects(host.activity({ cursor: "99999999999999999", limit: 1 }), /cursor is invalid/); + await host.stop(); + delete process.env[tokenEnv]; +}); + +test("stop rejects queued work, aborts active work, and is idempotent", async () => { + process.env[tokenEnv] = "test-token"; + const handle = new FakeHandle(true); + const host = createOrganizationRuntimeHostForTest(config(["alpha"]), async () => handle); + await host.start(); + const active = host.wake(wake("alpha", "active")); + const queued = host.wake(wake("alpha", "queued")); + await waitFor(() => handle.seen.length === 1); + const [first, second] = await Promise.all([host.stop(), host.stop()]); + assert.strictEqual(first, second); + assert.equal((await active).status, "stopped"); + assert.equal((await queued).status, "stopped"); + assert.equal((await host.wake(wake("alpha", "later"))).status, "stopped"); + delete process.env[tokenEnv]; +}); + +test("caps queued wakes and retains a bounded, monotonic activity ledger", async () => { + process.env[tokenEnv] = "test-token"; + const handle = new FakeHandle(true); + const host = createOrganizationRuntimeHostForTest(config(["alpha"]), async () => handle); + await host.start(); + const active = host.wake(wake("alpha", "active")); + await waitFor(() => handle.seen.length === 1); + const queued = Array.from({ length: 64 }, (_, index) => host.wake(wake("alpha", `queued-${index}`))); + const overflow = await host.wake(wake("alpha", "overflow")); + assert.equal(overflow.status, "rejected"); + if (overflow.status === "rejected") assert.equal(overflow.code, "queue_full"); + await host.stop(); + await active; + await Promise.all(queued); + const first = await host.activity({ limit: 100 }); + assert.ok(first.items.length <= 100); + const next = first.nextCursor; + if (next !== undefined) assert.ok(Number(next) > 0); + delete process.env[tokenEnv]; +}); + +test("does not claim shutdown after a child cleanup failure", async () => { + process.env[tokenEnv] = "test-token"; + const handle = new FakeHandle(); + handle.stop = async () => { throw new Error("stubborn child"); }; + const host = createOrganizationRuntimeHostForTest(config(["alpha"]), async () => handle); + await host.start(); + const first = host.stop(); + const second = host.stop(); + assert.strictEqual(first, second); + await assert.rejects(first, /shutdown cleanup failed/); + assert.equal((await host.health()).state, "stopping"); + delete process.env[tokenEnv]; +}); + +test("retries a retained startup-cleanup handle until shutdown is truthful", async () => { + process.env[tokenEnv] = "test-token"; + const handle = new FakeHandle(); + let stops = 0; + handle.stop = async () => { + stops += 1; + if (stops === 1) throw new Error("first cleanup failed"); + handle.release("anything"); + }; + const host = createOrganizationRuntimeHostForTest(config(), async (agent) => { + if (agent.id === "beta") throw new Error("second start failed"); + return handle; + }); + await assert.rejects(host.start(), /startup cleanup failed/); + assert.equal((await host.health()).state, "stopping"); + await host.stop(); + assert.equal(stops, 2); + assert.equal((await host.health()).state, "stopped"); + delete process.env[tokenEnv]; +}); + +test("cleanup failure still settles active and queued wakes exactly once", async () => { + process.env[tokenEnv] = "test-token"; + const handle = new FakeHandle(true); + handle.stop = async () => { throw new Error("child would not stop"); }; + const host = createOrganizationRuntimeHostForTest(config(["alpha"]), async () => handle); + await host.start(); + const active = host.wake(wake("alpha", "active")); + const queued = host.wake(wake("alpha", "queued")); + await waitFor(() => handle.seen.length === 1); + await assert.rejects(host.stop(), /shutdown cleanup failed/); + assert.deepEqual(await active, { + version: "noopolis.daimon.wake-result.v1", status: "stopped", agentId: "alpha", wakeId: "active", code: "active_wake_aborted" + }); + assert.deepEqual(await queued, { + version: "noopolis.daimon.wake-result.v1", status: "stopped", agentId: "alpha", wakeId: "queued", code: "queued_wake_stopped" + }); + delete process.env[tokenEnv]; +}); + +test("surfaces startup cleanup failure without becoming ready", async () => { + process.env[tokenEnv] = "test-token"; + const host = createOrganizationRuntimeHostForTest(config(), async (agent) => { + if (agent.id === "beta") throw new Error("second start failed"); + const handle = new FakeHandle(); + handle.stop = async () => { throw new Error("first child stayed alive"); }; + return handle; + }); + await assert.rejects(host.start(), /startup cleanup failed/); + assert.equal((await host.health()).state, "stopping"); + delete process.env[tokenEnv]; +}); + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + throw new Error("timed out waiting for fake engine"); +} diff --git a/src/runtime/organizationRuntimeHost.ts b/src/runtime/organizationRuntimeHost.ts new file mode 100644 index 0000000..04112a6 --- /dev/null +++ b/src/runtime/organizationRuntimeHost.ts @@ -0,0 +1,411 @@ +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; + +import type { AgentHandle, AgentStatus, WakeEvent } from "../core/types.js"; + +import { startAgySubscriptionRealm, type AgySubscriptionRealm } from "./agySubscriptionRealm.js"; +import { startOrganizationRuntimeEngine } from "./engineDispatcher.js"; +import { prepareEngineExecutable, verifyAgySubscriptionEnrollment } from "./engineReadiness.js"; +import { prepareOrganizationRuntimePaths, type OrganizationRuntimePathAuthority } from "./physicalReadiness.js"; +import { + parseOrganizationRuntimeConfig, + parseOrganizationRuntimeWakeRequest, + type OrganizationRuntimeActivity, + type OrganizationRuntimeActivityPage, + type OrganizationRuntimeActivityRequest, + type OrganizationRuntimeAgentConfig, + type OrganizationRuntimeAgentHealth, + type OrganizationRuntimeConfig, + type OrganizationRuntimeHealth, + type OrganizationRuntimeHost, + type OrganizationRuntimeLifecycleState, + type OrganizationRuntimeShutdownCompletion, + type OrganizationRuntimeWakeRequest, + type OrganizationRuntimeWakeResult +} from "./organizationRuntime.js"; + +type OrganizationRuntimeEngineFactory = ( + agent: OrganizationRuntimeAgentConfig, + paths?: ReturnType +) => Promise; + +type HostReadiness = Readonly<{ + agyRealm?: AgySubscriptionRealm; + paths: OrganizationRuntimePathAuthority; + close(): Promise; +}>; + +type WakeJob = { + readonly request: OrganizationRuntimeWakeRequest; + readonly resolve: (result: OrganizationRuntimeWakeResult) => void; + settled: boolean; + aborting: boolean; +}; + +type HostedAgent = { + readonly config: OrganizationRuntimeAgentConfig; + handle?: AgentHandle; + state: OrganizationRuntimeAgentHealth["state"]; + active?: WakeJob; + draining: boolean; + pending: WakeJob[]; + stopped: boolean; +}; + +/** Creates the public host with Daimon's closed production engine dispatcher. */ +export function createOrganizationRuntimeHost(config: unknown): OrganizationRuntimeHost { + const parsed = parseOrganizationRuntimeConfig(config); + let agyBusAddress: string | undefined; + return createHost( + parsed, + (agent, paths) => startOrganizationRuntimeEngine(agent, parsed.host.controlTokenEnv, paths, agyBusAddress), + async () => { + const ready = await prepareProductionReadiness(parsed); + agyBusAddress = ready.agyRealm?.busAddress; + return { + ...ready, + async close() { + try { await ready.close(); } finally { agyBusAddress = undefined; } + } + }; + } + ); +} + +/** @internal Test-only construction seam; it is intentionally not package-exported. */ +export function createOrganizationRuntimeHostForTest( + config: unknown, + factory: OrganizationRuntimeEngineFactory, + preflight?: () => Promise +): OrganizationRuntimeHost { + return createHost(parseOrganizationRuntimeConfig(config), factory, preflight === undefined ? undefined : async () => { + const paths = await preflight(); + return { paths, close: () => paths.close() }; + }); +} + +async function prepareProductionReadiness(config: OrganizationRuntimeConfig): Promise { + const paths = await prepareOrganizationRuntimePaths(config.agents); + let realm: AgySubscriptionRealm | undefined; + try { + const agy = config.agents.find((agent) => agent.engine.kind === "agy"); + if (agy !== undefined) { + realm = await startAgySubscriptionRealm(); + const canonical = paths.forAgent(agy); + await canonical.verify(); + const executable = await prepareEngineExecutable(agy.id, "agy"); + await verifyAgySubscriptionEnrollment(agy.id, executable.executablePath, canonical.runtimeHomePath, realm.busAddress); + await executable.verify(); + await canonical.verify(); + } + let closed = false; + return { + ...(realm === undefined ? {} : { agyRealm: realm }), + paths, + async close() { + if (closed) return; + closed = true; + const results = await Promise.allSettled([realm?.close(), paths.close()].filter((value): value is Promise => value !== undefined)); + const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + if (failures.length > 0) throw new AggregateError(failures, "organization runtime readiness cleanup failed"); + } + }; + } catch (error) { + const cleanup = await Promise.allSettled([realm?.close(), paths.close()].filter((value): value is Promise => value !== undefined)); + const failures = cleanup.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + if (failures.length > 0) throw new AggregateError([error, ...failures], "organization runtime readiness failed and cleanup was incomplete"); + throw error; + } +} + +function createHost( + config: OrganizationRuntimeConfig, + factory: OrganizationRuntimeEngineFactory, + preflight: (() => Promise) | undefined = undefined +): OrganizationRuntimeHost { + const maxPendingWakes = 64; + const maxActivity = 512; + const agents = new Map(config.agents.map((agent): [string, HostedAgent] => [agent.id, { + config: agent, + state: "stopped", + draining: false, + pending: [], + stopped: false + }])); + const activity: Array<{ sequence: number; value: OrganizationRuntimeActivity }> = []; + let nextActivitySequence = 0; + // Capture the expected token before any operation can route by agent id. + const controlToken = process.env[config.host.controlTokenEnv]; + let state: OrganizationRuntimeLifecycleState = "stopped"; + let startPromise: Promise | undefined; + let stopPromise: Promise | undefined; + let stopRequested = false; + let readiness: HostReadiness | undefined; + + const addActivity = (agentId: string, kind: OrganizationRuntimeActivity["kind"], wakeId?: string): void => { + activity.push({ + sequence: nextActivitySequence++, + value: { id: randomUUID(), agentId, ...(wakeId === undefined ? {} : { wakeId }), kind, occurredAt: new Date().toISOString() } + }); + if (activity.length > maxActivity) activity.shift(); + }; + + const stoppedResult = (request: OrganizationRuntimeWakeRequest, code: Extract["code"]): OrganizationRuntimeWakeResult => ({ + version: "noopolis.daimon.wake-result.v1", + status: "stopped", + agentId: request.agentId, + wakeId: request.event.id, + code + }); + + const settle = (job: WakeJob, result: OrganizationRuntimeWakeResult): void => { + if (job.settled) return; + job.settled = true; + job.resolve(result); + }; + + const drain = async (agent: HostedAgent): Promise => { + if (agent.draining) return; + agent.draining = true; + try { + while (state === "running" && agent.pending.length > 0) { + const job = agent.pending.shift(); + if (job === undefined) continue; + agent.active = job; + agent.state = "running"; + addActivity(agent.config.id, "wake_started", job.request.event.id); + try { + const result = await agent.handle!.wake(toCoreWake(job.request)); + if (!job.settled && !job.aborting) { + agent.state = "idle"; + addActivity(agent.config.id, "wake_completed", job.request.event.id); + settle(job, completed(job.request, result.text, result.durationMs)); + } + } catch { + if (!job.settled && !job.aborting) { + agent.state = "failed"; + settle(job, failed(job.request)); + } + } finally { + if (agent.active === job) agent.active = undefined; + } + } + } finally { + agent.draining = false; + } + }; + + const start = async (): Promise => { + if (state === "running") return; + if (startPromise !== undefined) return startPromise; + if (stopRequested) throw new Error("organization runtime host has been stopped"); + if (controlToken === undefined || !controlToken.trim()) { + throw new Error(`required control token ${config.host.controlTokenEnv} is missing or blank`); + } + state = "starting"; + for (const agent of agents.values()) agent.state = "starting"; + startPromise = (async () => { + // This runs before a factory can create an adapter or child process. + try { + readiness = await preflight?.(); + } catch (error) { + state = "stopped"; + for (const agent of agents.values()) agent.state = "stopped"; + throw error; + } + const started = await Promise.allSettled([...agents.values()].map(async (agent) => { + const paths = readiness?.paths.forAgent(agent.config); + await paths?.verify(); + agent.handle = await factory(agent.config, paths); + await paths?.verify(); + return agent; + })); + const failedStart = started.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (failedStart !== undefined || stopRequested) { + state = "stopping"; + stopRequested = true; + const startedAgents = [...agents.values()].filter((agent) => agent.handle !== undefined); + const stopped = await Promise.allSettled(startedAgents.map(async (agent) => { + await agent.handle!.stop(); + agent.handle = undefined; + agent.stopped = true; + agent.state = "stopped"; + })); + const cleanupFailures = stopped.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + for (const agent of agents.values()) { + if (agent.handle !== undefined) agent.state = "stopping"; + else if (!agent.stopped) agent.state = "stopped"; + } + if (cleanupFailures.length > 0) { + // A handle that cannot stop remains visible and retryable. + state = "stopping"; + throw new AggregateError([...(failedStart === undefined ? [] : [failedStart.reason]), ...cleanupFailures], "organization runtime startup cleanup failed"); + } + await readiness?.close(); + readiness = undefined; + state = "stopped"; + if (failedStart !== undefined) throw failedStart.reason; + return; + } + try { + state = "running"; + for (const agent of agents.values()) agent.state = "idle"; + } catch (error) { + state = "stopping"; + await Promise.allSettled([...agents.values()].flatMap((agent) => agent.handle === undefined ? [] : [agent.handle.stop()])); + await readiness?.close(); readiness = undefined; + for (const agent of agents.values()) { agent.handle = undefined; agent.state = "stopped"; } + state = "stopped"; + throw error; + } + })().finally(() => { + startPromise = undefined; + }); + return startPromise; + }; + + const wake = async (request: OrganizationRuntimeWakeRequest): Promise => { + let parsed: OrganizationRuntimeWakeRequest; + try { + parsed = parseOrganizationRuntimeWakeRequest(request); + } catch { + return invalidRequest(); + } + if (controlToken === undefined || !tokensEqual(controlToken, parsed.token)) { + return rejected(parsed, "unauthorized"); + } + if (state !== "running" || stopRequested) { + return stoppedResult(parsed, stopRequested || state === "stopping" ? "host_stopping" : "host_stopped"); + } + const agent = agents.get(parsed.agentId); + if (agent === undefined) { + addActivity(parsed.agentId, "wake_rejected", parsed.event.id); + return rejected(parsed, "unknown_agent"); + } + if (agent.pending.length >= maxPendingWakes) { + addActivity(parsed.agentId, "wake_rejected", parsed.event.id); + return rejected(parsed, "queue_full"); + } + return new Promise((resolve) => { + const job: WakeJob = { request: parsed, resolve, settled: false, aborting: false }; + agent.pending.push(job); + void drain(agent); + }); + }; + + const health = async (agentId?: string): Promise => ({ + version: "noopolis.daimon.organization-runtime-health.v1", + state, + agents: [...agents.values()] + .filter((agent) => agentId === undefined || agent.config.id === agentId) + .map((agent) => ({ agentId: agent.config.id, state: agentHealthState(agent) })) + }); + + const activityPage = async (request: OrganizationRuntimeActivityRequest): Promise => { + const offset = cursorOffset(request.cursor); + if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 100) { + throw new TypeError("activity.limit must be an integer between 1 and 100"); + } + const filtered = activity.filter((entry) => entry.sequence >= offset + && (request.agentId === undefined || entry.value.agentId === request.agentId)); + const entries = filtered.slice(0, request.limit); + const items = entries.map((entry) => entry.value); + const next = entries.length === 0 ? undefined : entries[entries.length - 1]!.sequence + 1; + return { + version: "noopolis.daimon.organization-runtime-activity.v1", + items, + ...(next !== undefined && filtered.length > entries.length ? { nextCursor: String(next) } : {}) + }; + }; + + const stop = (): Promise => { + if (stopPromise !== undefined) return stopPromise; + stopRequested = true; + const attempt: Promise = (async () => { + if (startPromise !== undefined) await startPromise.catch(() => undefined); + if (state === "stopped") return { version: "noopolis.daimon.organization-runtime-stop.v1", state: "stopped" }; + state = "stopping"; + const active: Array<{ agent: HostedAgent; job: WakeJob }> = []; + for (const agent of agents.values()) { + for (const job of agent.pending.splice(0)) settle(job, stoppedResult(job.request, "queued_wake_stopped")); + if (agent.active !== undefined) { + agent.active.aborting = true; + active.push({ agent, job: agent.active }); + } + } + const liveAgents = [...agents.values()].filter((agent) => agent.handle !== undefined && !agent.stopped); + const stopped = await Promise.allSettled(liveAgents.map(async (agent) => { + await agent.handle!.stop(); + agent.handle = undefined; + agent.stopped = true; + agent.state = "stopped"; + })); + const failures = stopped.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + for (const { agent, job } of active) { + addActivity(agent.config.id, "wake_aborted", job.request.event.id); + settle(job, stoppedResult(job.request, "active_wake_aborted")); + } + if (failures.length > 0) { + for (const agent of liveAgents) if (agent.handle !== undefined) agent.state = "stopping"; + throw new AggregateError(failures, "organization runtime shutdown cleanup failed"); + } + await readiness?.close(); + readiness = undefined; + for (const agent of agents.values()) agent.stopped = true; + for (const agent of agents.values()) { + agent.state = "stopped"; + addActivity(agent.config.id, "agent_stopped"); + } + state = "stopped"; + return { version: "noopolis.daimon.organization-runtime-stop.v1", state: "stopped" }; + })(); + stopPromise = attempt; + void attempt.catch(() => { if (stopPromise === attempt) stopPromise = undefined; }); + return attempt; + }; + + return { start, wake, health, activity: activityPage, stop }; +} + +function toCoreWake(request: OrganizationRuntimeWakeRequest): WakeEvent { + return { + id: request.event.id, + kind: request.event.kind === "external" ? "manual" : request.event.kind, + text: request.event.text + }; +} + +function agentHealthState(agent: HostedAgent): OrganizationRuntimeAgentHealth["state"] { + const status: AgentStatus | undefined = agent.handle?.status(); + return agent.state === "running" || agent.state === "failed" || agent.state === "stopped" + ? agent.state + : status?.state ?? agent.state; +} + +function tokensEqual(expected: string, actual: string | undefined): boolean { + const digest = (value: string): Buffer => createHash("sha256").update(value).digest(); + return timingSafeEqual(digest(expected), digest(actual ?? "")); +} + +function cursorOffset(cursor: string | undefined): number { + if (cursor === undefined) return 0; + if (!/^(0|[1-9][0-9]{0,15})$/.test(cursor)) throw new TypeError("activity.cursor is invalid"); + const offset = Number(cursor); + if (!Number.isSafeInteger(offset)) throw new TypeError("activity.cursor is invalid"); + return offset; +} + +function completed(request: OrganizationRuntimeWakeRequest, text: string, durationMs: number): OrganizationRuntimeWakeResult { + return { version: "noopolis.daimon.wake-result.v1", status: "completed", agentId: request.agentId, wakeId: request.event.id, text, durationMs }; +} + +function failed(request: OrganizationRuntimeWakeRequest): OrganizationRuntimeWakeResult { + return { version: "noopolis.daimon.wake-result.v1", status: "failed", agentId: request.agentId, wakeId: request.event.id, code: "engine_failed" }; +} + +function rejected(request: OrganizationRuntimeWakeRequest, code: "unauthorized" | "unknown_agent" | "queue_full"): OrganizationRuntimeWakeResult { + return { version: "noopolis.daimon.wake-result.v1", status: "rejected", agentId: request.agentId, wakeId: request.event.id, code }; +} + +function invalidRequest(): OrganizationRuntimeWakeResult { + return { version: "noopolis.daimon.wake-result.v1", status: "rejected", agentId: "", wakeId: "", code: "invalid_request" }; +} diff --git a/src/runtime/organizationRuntimeParsing.ts b/src/runtime/organizationRuntimeParsing.ts new file mode 100644 index 0000000..37babe3 --- /dev/null +++ b/src/runtime/organizationRuntimeParsing.ts @@ -0,0 +1,120 @@ +import { + ORGANIZATION_RUNTIME_MAX_AGENTS, + ORGANIZATION_RUNTIME_MAX_CONFIG_BYTES, + ORGANIZATION_RUNTIME_MAX_STRING_BYTES, + ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, + ORGANIZATION_RUNTIME_MAX_WAKE_TEXT_BYTES, + ORGANIZATION_RUNTIME_VERSION, + type OrganizationRuntimeAgentConfig, + type OrganizationRuntimeConfig, + type OrganizationRuntimeEngineIntent, + type OrganizationRuntimeEngineKind, + type OrganizationRuntimeHostConfig, + type OrganizationRuntimeWakeRequest +} from "./organizationRuntime.js"; + +type RecordValue = Record; +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +const ENGINE_KINDS = new Set(["codex", "grok", "agy"]); + +export function parseOrganizationRuntimeConfig(value: unknown): OrganizationRuntimeConfig { + const root = object(snapshot(value, "config"), "config"); + size(root); + exact(root, ["version", "host", "agents"], "config"); + if (string(root.version, "config.version") !== ORGANIZATION_RUNTIME_VERSION) throw new TypeError(`config.version must equal ${ORGANIZATION_RUNTIME_VERSION}`); + const hostValue = object(root.host, "config.host"); + exact(hostValue, ["bindHost", "port", "controlTokenEnv"], "config.host"); + const host: OrganizationRuntimeHostConfig = { + bindHost: nonEmpty(hostValue.bindHost, "config.host.bindHost"), port: port(hostValue.port, "config.host.port"), controlTokenEnv: envName(hostValue.controlTokenEnv, "config.host.controlTokenEnv") + }; + const rawAgents = array(root.agents, "config.agents"); + if (rawAgents.length === 0 || rawAgents.length > ORGANIZATION_RUNTIME_MAX_AGENTS) throw new TypeError(`config.agents must contain between 1 and ${ORGANIZATION_RUNTIME_MAX_AGENTS} agents`); + const ids = new Set(); + const agents = rawAgents.map((item, index) => { + const agent = parseAgent(item, `config.agents[${index}]`); + if (ids.has(agent.id)) throw new TypeError(`config.agents has duplicate id ${agent.id}`); + ids.add(agent.id); + return agent; + }); + isolated(agents); + return { version: ORGANIZATION_RUNTIME_VERSION, host, agents }; +} + +export function parseOrganizationRuntimeWakeRequest(value: unknown): OrganizationRuntimeWakeRequest { + const request = object(snapshot(value, "wake request"), "wake request"); + exact(request, ["token", "agentId", "event"], "wake request"); + const event = object(request.event, "wake request.event"); + exact(event, ["version", "id", "kind", "text", "occurredAt"], "wake request.event"); + if (string(event.version, "wake request.event.version") !== "noopolis.daimon.wake.v1") throw new TypeError("wake request.event.version is not supported"); + const kind = string(event.kind, "wake request.event.kind"); + if (kind !== "manual" && kind !== "message" && kind !== "external") throw new TypeError("wake request.event.kind is not supported"); + const text = string(event.text, "wake request.event.text"); + if (Buffer.byteLength(text, "utf8") > ORGANIZATION_RUNTIME_MAX_WAKE_TEXT_BYTES) throw new TypeError("wake request.event.text exceeds the wake text limit"); + return { token: request.token === undefined ? undefined : string(request.token, "wake request.token"), agentId: nonEmpty(request.agentId, "wake request.agentId"), event: { version: "noopolis.daimon.wake.v1", id: nonEmpty(event.id, "wake request.event.id"), kind, text, occurredAt: rfc3339(event.occurredAt) } }; +} + +export function validateOrganizationRuntimeConfig(value: unknown): value is OrganizationRuntimeConfig { + try { parseOrganizationRuntimeConfig(value); return true; } catch { return false; } +} + +export const isOrganizationRuntimeConfig = validateOrganizationRuntimeConfig; + +function parseAgent(value: unknown, label: string): OrganizationRuntimeAgentConfig { + const agent = object(value, label); + exact(agent, ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine"], label); + return { id: nonEmpty(agent.id, `${label}.id`), name: nonEmpty(agent.name, `${label}.name`), instructions: nonEmpty(agent.instructions, `${label}.instructions`), workspacePath: absolute(agent.workspacePath, `${label}.workspacePath`), runtimeHomePath: absolute(agent.runtimeHomePath, `${label}.runtimeHomePath`), engine: engine(agent.engine, `${label}.engine`) }; +} + +function engine(value: unknown, label: string): OrganizationRuntimeEngineIntent { + const input = object(value, label); const kind = string(input.kind, `${label}.kind`); + if (!ENGINE_KINDS.has(kind)) throw new TypeError(`${label}.kind is not a supported engine`); + exact(input, ["kind"], label); + return { kind: kind as OrganizationRuntimeEngineKind }; +} + +function snapshot(value: unknown, label: string): unknown { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") { if (!Number.isFinite(value)) throw new TypeError(`${label} must be JSON data`); return value; } + if (typeof value !== "object") throw new TypeError(`${label} must be JSON data`); + if (Array.isArray(value)) { + const result: unknown[] = []; + for (const key of Reflect.ownKeys(value)) { + if (key === "length") continue; + if (typeof key !== "string" || !/^(0|[1-9][0-9]*)$/.test(key)) throw new TypeError(`${label} must contain only indexed data properties`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) throw new TypeError(`${label}[${String(key)}] must be an enumerable data property`); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !("value" in descriptor)) throw new TypeError(`${label}[${index}] must be a present record`); + result.push(snapshot(descriptor.value, `${label}[${index}]`)); + } + return result; + } + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError(`${label} must be a plain own-properties object`); + const result: RecordValue = Object.create(null) as RecordValue; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string") throw new TypeError(`${label} must not contain symbol properties`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) throw new TypeError(`${label}.${key} must be an enumerable data property`); + result[key] = snapshot(descriptor.value, `${label}.${key}`); + } + return result; +} + +function object(value: unknown, label: string): RecordValue { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${label} must be an object`); + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError(`${label} must be a plain own-properties object`); + return value as RecordValue; +} +function array(value: unknown, label: string): readonly unknown[] { if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`); return value; } +function exact(value: RecordValue, expected: readonly string[], label: string): void { const extras = Object.keys(value).filter((key) => !expected.includes(key)); const missing = expected.filter((key) => !Object.hasOwn(value, key)); if (extras.length || missing.length) throw new TypeError(`${label} must contain exactly ${expected.join(", ")}`); } +function isolated(agents: readonly OrganizationRuntimeAgentConfig[]): void { const paths = agents.flatMap((agent) => [{ agentId: agent.id, kind: "workspacePath", value: agent.workspacePath }, { agentId: agent.id, kind: "runtimeHomePath", value: agent.runtimeHomePath }]); for (let left = 0; left < paths.length; left += 1) for (let right = left + 1; right < paths.length; right += 1) { const first = paths[left]!; const second = paths[right]!; if (first.value === second.value || first.value.startsWith(`${second.value}/`) || second.value.startsWith(`${first.value}/`)) throw new TypeError(`agents ${first.agentId}.${first.kind} and ${second.agentId}.${second.kind} must not overlap`); } } +function string(value: unknown, label: string): string { if (typeof value !== "string") throw new TypeError(`${label} must be a string`); if (Buffer.byteLength(value, "utf8") > ORGANIZATION_RUNTIME_MAX_STRING_BYTES || Array.from(value).length > ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS) throw new TypeError(`${label} exceeds the runtime string limit`); return value; } +function nonEmpty(value: unknown, label: string): string { const result = string(value, label); if (!result.trim()) throw new TypeError(`${label} must not be empty`); return result; } +function absolute(value: unknown, label: string): string { const result = nonEmpty(value, label); if (!path.posix.isAbsolute(result)) throw new TypeError(`${label} must be an absolute POSIX path`); const normalized = path.posix.normalize(result); if (normalized === "/") throw new TypeError(`${label} must not overlap filesystem root`); return normalized.replace(/\/+$/, ""); } +function envName(value: unknown, label: string): string { const result = nonEmpty(value, label); if (!ENV_NAME.test(result)) throw new TypeError(`${label} must be a safe environment variable name`); return result; } +function port(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65_535) throw new TypeError(`${label} must be an integer between 1 and 65535`); return value; } +function size(value: unknown): void { const serialized = JSON.stringify(value); if (serialized === undefined || Buffer.byteLength(serialized, "utf8") > ORGANIZATION_RUNTIME_MAX_CONFIG_BYTES) throw new TypeError("config exceeds the runtime byte limit"); } +function rfc3339(value: unknown): string { const result = nonEmpty(value, "wake request.event.occurredAt"); if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(result) || Number.isNaN(Date.parse(result)) || new Date(result).toISOString() !== result) throw new TypeError("wake request.event.occurredAt must be an exact RFC3339 timestamp"); return result; } +import path from "node:path"; diff --git a/src/runtime/physicalReadiness.test.ts b/src/runtime/physicalReadiness.test.ts new file mode 100644 index 0000000..bdbb248 --- /dev/null +++ b/src/runtime/physicalReadiness.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { chmod, lstat, mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { prepareOrganizationRuntimePaths } from "./physicalReadiness.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +const agent = (workspacePath: string, runtimeHomePath: string): OrganizationRuntimeAgentConfig => ({ + id: "agent", name: "Agent", instructions: "Work.", workspacePath, runtimeHomePath, engine: { kind: "codex" } +}); + +test("preflight rejects missing or linked caller roots without creating anything", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-physical-")); + const real = path.join(root, "real"); + const linked = path.join(root, "linked"); + const missing = path.join(root, "missing"); + const home = path.join(root, "home"); + try { + await mkdir(real, { mode: 0o700 }); + await mkdir(home, { mode: 0o700 }); + await symlink(real, linked); + await assert.rejects(prepareOrganizationRuntimePaths([agent(linked, home)]), /symlink/); + await assert.rejects(prepareOrganizationRuntimePaths([agent(missing, home)]), /ENOENT/); + await assert.rejects(lstat(missing), /ENOENT/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("preflight requires safe workspace and private runtime roots, and proves physical isolation", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-physical-")); + const workspace = path.join(root, "workspace"); + const home = path.join(root, "home"); + try { + await mkdir(workspace, { mode: 0o700 }); + await mkdir(home, { mode: 0o700 }); + const authority = await prepareOrganizationRuntimePaths([agent(workspace, home)]); + await authority.forAgent(agent(workspace, home)).verify(); + await authority.close(); + await chmod(home, 0o755); + await assert.rejects(prepareOrganizationRuntimePaths([agent(workspace, home)]), /mode 0700/); + await chmod(home, 0o700); + await chmod(workspace, 0o777); + await assert.rejects(prepareOrganizationRuntimePaths([agent(workspace, home)]), /write access/); + await chmod(workspace, 0o700); + await assert.rejects(prepareOrganizationRuntimePaths([agent(workspace, workspace)]), /overlap/); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/runtime/physicalReadiness.ts b/src/runtime/physicalReadiness.ts new file mode 100644 index 0000000..075ea47 --- /dev/null +++ b/src/runtime/physicalReadiness.ts @@ -0,0 +1,135 @@ +import { constants, type Stats } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import path from "node:path"; + +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +type Identity = Readonly<{ dev: number; ino: number; uid: number; mode: number }>; +type Directory = { readonly configured: string; readonly real: string; readonly fd: Awaited>; readonly identity: Identity; closed: boolean }; + +/** + * Holds identities for caller-created roots. Daimon neither creates nor rolls + * back these directories: the deployer owns their lifecycle and contents. + */ +export type OrganizationRuntimePathAuthority = Readonly<{ + forAgent(agent: OrganizationRuntimeAgentConfig): Readonly<{ + workspacePath: string; + runtimeHomePath: string; + verify(): Promise; + }>; + close(): Promise; +}>; + +export async function prepareOrganizationRuntimePaths( + agents: readonly OrganizationRuntimeAgentConfig[] +): Promise { + const workspaces = new Map(); + const homes = new Map(); + try { + for (const agent of agents) { + workspaces.set(agent.id, await verifyDirectory(agent.workspacePath, "workspacePath", "safe")); + homes.set(agent.id, await verifyDirectory(agent.runtimeHomePath, "runtimeHomePath", "private")); + } + const roots = [...workspaces.values(), ...homes.values()]; + for (let left = 0; left < roots.length; left += 1) for (let right = left + 1; right < roots.length; right += 1) { + const first = roots[left]!; + const second = roots[right]!; + if (sameIdentity(first.identity, second.identity) || overlaps(first.real, second.real)) { + throw new Error(`physical runtime paths overlap: ${first.configured} and ${second.configured}`); + } + } + } catch (error) { + const closeError = await closeAll([...workspaces.values(), ...homes.values()]); + if (closeError !== undefined) throw new AggregateError([error, closeError], "runtime path validation cleanup failed"); + throw error; + } + let closed = false; + const verify = async (agent: OrganizationRuntimeAgentConfig): Promise => { + if (closed) throw new Error("runtime path authority is closed"); + const workspace = workspaces.get(agent.id); + const home = homes.get(agent.id); + if (workspace === undefined || home === undefined) throw new Error(`no runtime path authority for ${agent.id}`); + await Promise.all([ + verifyIdentity(workspace, "workspacePath", "safe"), + verifyIdentity(home, "runtimeHomePath", "private") + ]); + }; + return { + forAgent(agent) { + const workspace = workspaces.get(agent.id); + const home = homes.get(agent.id); + if (workspace === undefined || home === undefined) throw new Error(`no runtime path authority for ${agent.id}`); + return { workspacePath: workspace.real, runtimeHomePath: home.real, verify: () => verify(agent) }; + }, + async close() { + if (closed) return; + const failure = await closeAll([...workspaces.values(), ...homes.values()]); + if (failure !== undefined) throw failure; + closed = true; + } + }; +} + +async function verifyDirectory(configured: string, label: string, mode: "safe" | "private"): Promise { + await assertNoSymlinkComponents(configured); + const before = await lstat(configured); + assertDirectory(before, label, mode); + const fd = await open(configured, constants.O_RDONLY | directoryFlag() | noFollow()); + try { + const opened = await fd.stat(); + const real = await realpath(configured); + const after = await lstat(configured); + if (!sameIdentity(identity(before), identity(opened)) || !sameIdentity(identity(before), identity(after))) { + throw new Error(`${label} changed during validation`); + } + return { configured, real, fd, identity: identity(before), closed: false }; + } catch (error) { + await fd.close().catch(() => undefined); + throw error; + } +} + +async function verifyIdentity(directory: Directory, label: string, mode: "safe" | "private"): Promise { + if (directory.closed) throw new Error(`${label} authority is closed`); + await assertNoSymlinkComponents(directory.configured); + const entry = await lstat(directory.configured); + const opened = await directory.fd.stat(); + if (!sameIdentity(identity(entry), directory.identity) || !sameIdentity(identity(opened), directory.identity)) { + throw new Error(`${label} changed after readiness validation`); + } + assertDirectory(entry, label, mode); + if (await realpath(directory.configured) !== directory.real) throw new Error(`${label} changed after readiness validation`); +} + +async function assertNoSymlinkComponents(target: string): Promise { + const parsed = path.parse(target); + let current = parsed.root; + for (const part of path.relative(parsed.root, target).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + // macOS exposes /var as a system compatibility symlink to /private/var. + // It is an OS-root alias, not a caller-controlled component. + if ((await lstat(current)).isSymbolicLink() && current !== "/var") throw new Error(`path contains symlink: ${current}`); + } +} + +function assertDirectory(entry: Stats, label: string, mode: "safe" | "private"): void { + if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`${label} must be an existing real directory`); + if (entry.uid !== process.getuid?.()) throw new Error(`${label} must be owned by the runtime user`); + const permissions = entry.mode & 0o777; + if (mode === "private" && permissions !== 0o700) throw new Error(`${label} must have mode 0700`); + if (mode === "safe" && (permissions & 0o022) !== 0) throw new Error(`${label} must not grant group or other write access`); +} + +function identity(entry: Stats): Identity { return { dev: entry.dev, ino: entry.ino, uid: entry.uid, mode: entry.mode & 0o7777 }; } +function sameIdentity(left: Identity, right: Identity): boolean { return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode; } +function overlaps(left: string, right: string): boolean { return left === right || left.startsWith(`${right}${path.sep}`) || right.startsWith(`${left}${path.sep}`); } +function noFollow(): number { return (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; } +function directoryFlag(): number { return (constants as typeof constants & { O_DIRECTORY?: number }).O_DIRECTORY ?? 0; } +async function closeAll(directories: readonly Directory[]): Promise { + const results = await Promise.allSettled(directories.filter((directory) => !directory.closed).map(async (directory) => { + await directory.fd.close(); + directory.closed = true; + })); + const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + return failures.length === 0 ? undefined : new AggregateError(failures, "runtime path authority cleanup failed"); +} diff --git a/src/runtime/storeCoordination.ts b/src/runtime/storeCoordination.ts new file mode 100644 index 0000000..ebc2c29 --- /dev/null +++ b/src/runtime/storeCoordination.ts @@ -0,0 +1,73 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, open, readFile, readdir, unlink } from "node:fs/promises"; +import path from "node:path"; + +export type StoreProcessIdentity = Readonly<{ pid: number; process_start: string; boot_id: string; pid_namespace_dev: number; pid_namespace_ino: number }>; +export type StoreHostRegistration = StoreProcessIdentity & Readonly<{ owner_id: string }>; +export type StoreHostRegistrationIdentity = StoreHostRegistration & Readonly<{ dev: number; ino: number }>; + +export async function acquireHostRegistration(root: string, directory: Awaited>, identity: StoreProcessIdentity): Promise { + const registration: StoreHostRegistration = { owner_id: randomUUID(), ...identity }; + const target = path.join(root, `.host-online-${registration.owner_id}.json`); + await writeNew(target, registration); + await directory.sync(); + if (await exists(path.join(root, ".offline-reconciliation.lock"))) { + await unlink(target); + await directory.sync(); + throw new Error("wake acceptance store is reserved for offline reconciliation"); + } + return registration; +} + +export async function releaseHostRegistration(root: string, directory: Awaited>, registration: StoreHostRegistration): Promise { + const target = path.join(root, `.host-online-${registration.owner_id}.json`); + const current = await readRegistration(target); + if (!sameOwner(current, registration)) throw new Error("wake acceptance host registration changed"); + await unlink(target); + await directory.sync(); +} + +/** Admin callers hold their durable offline lease before calling this. */ +export async function listHostRegistrations(root: string): Promise { const result: StoreHostRegistrationIdentity[] = []; for (const entry of await readdir(root)) { if (/^\.host-online-[0-9a-f-]{36}\.json$/iu.test(entry)) result.push(await readRegistration(path.join(root, entry))); } return result; } +/** Returns undefined when any registration is live, unknown, or changed. */ +export async function clearDeadHostRegistrations(root: string, directory: Awaited>, identity: StoreProcessIdentity, liveness: (owner: StoreHostRegistration) => Promise, authorizedCrossNamespace: ReadonlySet): Promise { + const cleared: string[] = []; + for (const entry of await readdir(root)) { + if (!/^\.host-online-[0-9a-f-]{36}\.json$/iu.test(entry)) continue; + const target = path.join(root, entry); + const owner = await readRegistration(target); + const digest = hostRegistrationDigest(owner); + if (!sameNamespace(owner, identity) && !authorizedCrossNamespace.has(digest)) return undefined; + if (sameNamespace(owner, identity) && await liveness(owner)) return undefined; + const checked = await readRegistration(target); + if (!sameIdentity(checked, owner)) return undefined; + await unlink(target); + await directory.sync(); + cleared.push(digest); + } + return cleared; +} + +async function readRegistration(target: string): Promise { + const entry = await lstat(target); + if (!entry.isFile() || entry.isSymbolicLink() || Number(entry.uid) !== process.getuid?.() || (Number(entry.mode) & 0o777) !== 0o600 || Number(entry.size) > 1024) throw new Error("wake acceptance host registration is unsafe"); + const value: unknown = JSON.parse((await readFile(target)).toString("utf8")); + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("wake acceptance host registration is invalid"); + const record = value as Record; + const keys = ["owner_id", "pid", "process_start", "boot_id", "pid_namespace_dev", "pid_namespace_ino"]; + if (Object.keys(record).length !== keys.length || keys.some((key) => !Object.hasOwn(record, key))) throw new Error("wake acceptance host registration is invalid"); + const result: StoreHostRegistrationIdentity = { dev: Number(entry.dev), ino: Number(entry.ino), owner_id: uuid(record.owner_id), pid: positive(record.pid), process_start: text(record.process_start), boot_id: text(record.boot_id), pid_namespace_dev: positive(record.pid_namespace_dev), pid_namespace_ino: positive(record.pid_namespace_ino) }; + if (!result.process_start || !result.boot_id) throw new Error("wake acceptance host registration is invalid"); + return result; +} +async function writeNew(target: string, value: unknown): Promise { const handle = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow(), 0o600); try { await handle.writeFile(JSON.stringify(value)); await handle.sync(); } finally { await handle.close(); } } +async function exists(target: string): Promise { try { await lstat(target); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } } +function sameOwner(left: StoreHostRegistration, right: StoreHostRegistration): boolean { return left.owner_id === right.owner_id && left.pid === right.pid && left.process_start === right.process_start && left.boot_id === right.boot_id && sameNamespace(left, right); } +function sameIdentity(left: StoreHostRegistrationIdentity, right: StoreHostRegistrationIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && sameOwner(left, right); } +export function hostRegistrationDigest(value: StoreHostRegistrationIdentity): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } +function sameNamespace(left: Pick, right: Pick): boolean { return left.pid_namespace_dev === right.pid_namespace_dev && left.pid_namespace_ino === right.pid_namespace_ino; } +function text(value: unknown): string { if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 1024) throw new Error("wake acceptance host registration is invalid"); return value; } +function uuid(value: unknown): string { const result = text(value); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(result)) throw new Error("wake acceptance host registration is invalid"); return result; } +function positive(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) throw new Error("wake acceptance host registration is invalid"); return value; } +function noFollow(): number { return (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; } diff --git a/src/runtime/wakeAcceptance.test.ts b/src/runtime/wakeAcceptance.test.ts new file mode 100644 index 0000000..9e620b1 --- /dev/null +++ b/src/runtime/wakeAcceptance.test.ts @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { ORGANIZATION_RUNTIME_VERSION, type OrganizationRuntimeHost, type OrganizationRuntimeWakeRequest } from "./organizationRuntime.js"; +import { createOrganizationRuntimeControlHostWithCoreForTest } from "./organizationRuntimeControl.js"; +import { WakeAcceptanceStore, WakeTransitionLockBlockedError } from "./wakeAcceptanceStore.js"; +import { parseWakeAcceptanceRequest } from "./wakeAcceptanceTypes.js"; + +const token = "control-secret"; +const config = { + version: ORGANIZATION_RUNTIME_VERSION, + host: { bindHost: "0.0.0.0", port: 4318, controlTokenEnv: "DAIMON_CONTROL_TEST_TOKEN" }, + agents: [{ id: "alpha", name: "Alpha", instructions: "Act.", workspacePath: "/runtime/workspace", runtimeHomePath: "/runtime/home", engine: { kind: "codex" as const } }] +}; +const request = (delivery = "delivery-1", text = "hello") => ({ token, agent_id: "alpha", delivery_id: delivery, event: { version: "noopolis.daimon.wake.v2", kind: "manual" as const, text, occurred_at: "2026-08-17T00:00:00.000Z" } }); +const testStoreOptions = { processIdentity: async () => ({ pid: 1, process_start: "test-start", boot_id: "test-boot", pid_namespace_dev: 1, pid_namespace_ino: 1 }), ownerLiveness: async () => true }; + +test("acceptance store is durable, idempotent, bounded, and recovers accepted work", async () => { + const root = await privateRoot(); + try { + const first = await WakeAcceptanceStore.open(root, testStoreOptions); + const accepted = await first.accept(parseWakeAcceptanceRequest(request())); + const duplicate = await first.accept(parseWakeAcceptanceRequest(request())); + assert.equal(accepted.created, true); + assert.equal(duplicate.created, false); + assert.equal(accepted.record.acceptance_id, duplicate.record.acceptance_id); + await assert.rejects(first.accept(parseWakeAcceptanceRequest(request("delivery-1", "different"))), /different request/); + await first.close(); + + const recovered = await WakeAcceptanceStore.open(root, testStoreOptions); + const pending = await recovered.recoverable(new Set(["alpha"])); + assert.equal(pending.length, 1); + assert.equal(pending[0]?.delivery_id, "delivery-1"); + const status = await recovered.status(accepted.record.acceptance_id); + assert.deepEqual(Object.keys(status ?? {}).sort(), ["acceptance_id", "accepted_at", "agent_id", "delivery_id", "request_digest", "state", "updated_at", "version"]); + await recovered.close(); + const core = new FakeCoreHost(); + const restarted = createOrganizationRuntimeControlHostWithCoreForTest(config, core, { acceptanceStorePath: root, controlToken: token, storeOptions: testStoreOptions }); + await restarted.start(); + await waitFor(() => core.wakes.length === 1); + core.release(); + await waitFor(async () => (await restarted.wakeReceipt(token, accepted.record.acceptance_id))?.state === "completed"); + await restarted.stop(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("control accepts before a fake turn finishes, redacts status, and rejects conflicts", async () => { + const root = await privateRoot(); + const core = new FakeCoreHost(); + const control = createOrganizationRuntimeControlHostWithCoreForTest(config, core, { acceptanceStorePath: root, controlToken: token, storeOptions: testStoreOptions }); + try { + await control.start(); + const accepted = await control.accept(request()); + assert.equal(accepted.state, "accepted"); + if (accepted.state !== "accepted") throw new Error("expected acceptance"); + await waitFor(() => core.wakes.length === 1); + const running = await control.wakeReceipt(token, accepted.acceptance_id); + assert.equal(running?.state, "running"); + assert.equal("text" in (running ?? {}), false); + assert.equal((await control.accept(request("delivery-1", "different"))).state, "rejected"); + core.release(); + await waitFor(async () => (await control.wakeReceipt(token, accepted.acceptance_id))?.state === "completed"); + assert.equal(await control.wakeReceipt("wrong", accepted.acceptance_id), undefined); + await control.stop(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("two control hosts cannot execute one accepted delivery concurrently", async () => { + const root = await privateRoot(); + const firstCore = new FakeCoreHost(); + const secondCore = new FakeCoreHost(); + const first = createOrganizationRuntimeControlHostWithCoreForTest(config, firstCore, { acceptanceStorePath: root, controlToken: token, storeOptions: testStoreOptions }); + const second = createOrganizationRuntimeControlHostWithCoreForTest(config, secondCore, { acceptanceStorePath: root, controlToken: token, storeOptions: testStoreOptions }); + try { + await Promise.all([first.start(), second.start()]); + const accepted = await first.accept(request("shared")); + assert.equal(accepted.state, "accepted"); + await second.accept(request("shared")); + await waitFor(() => firstCore.wakes.length + secondCore.wakes.length === 1); + assert.equal(firstCore.wakes.length + secondCore.wakes.length, 1); + firstCore.release(); + secondCore.release(); + await first.stop(); + await second.stop(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("stale running claims recover with a new fence and reject an old terminal write", async () => { + const root = await privateRoot(); + try { + const store = await WakeAcceptanceStore.open(root, { ...testStoreOptions, claimTtlMs: 500 }); + const accepted = await store.accept(parseWakeAcceptanceRequest(request("crash-window"))); + const first = await store.acquireClaim(accepted.record.acceptance_id, "11111111-1111-4111-8111-111111111111"); + assert.equal(first.state, "acquired"); + if (first.state !== "acquired") throw new Error("claim missing"); + await store.transitionClaimed(accepted.record.acceptance_id, first.claim, "running"); + await new Promise((resolve) => setTimeout(resolve, 600)); + const second = await store.acquireClaim(accepted.record.acceptance_id, "22222222-2222-4222-8222-222222222222"); + assert.equal(second.state, "acquired"); + await assert.rejects(store.transitionClaimed(accepted.record.acceptance_id, first.claim, "completed"), /claim was lost/); + if (second.state === "acquired") { + await store.transitionClaimed(accepted.record.acceptance_id, second.claim, "running"); + await store.transitionClaimed(accepted.record.acceptance_id, second.claim, "failed", "engine_failed"); + } + await store.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("a takeover between claim check and replacement fences the old writer", async () => { + const root = await privateRoot(); + try { + const setup = await WakeAcceptanceStore.open(root, { ...testStoreOptions, claimTtlMs: 200 }); + const accepted = await setup.accept(parseWakeAcceptanceRequest(request("interleaving"))); + const first = await setup.acquireClaim(accepted.record.acceptance_id, "33333333-3333-4333-8333-333333333333"); + if (first.state !== "acquired") throw new Error("initial claim missing"); + await setup.transitionClaimed(accepted.record.acceptance_id, first.claim, "running"); + await setup.close(); + + let checked!: () => void; + let resume!: () => void; + const reached = new Promise((resolve) => { checked = resolve; }); + const paused = new Promise((resolve) => { resume = resolve; }); + let firstOwnerLive = true; + const ownerLiveness = async (lock: { readonly owner_id: string }): Promise => lock.owner_id === first.claim.owner_id && firstOwnerLive; + const old = await WakeAcceptanceStore.open(root, { ...testStoreOptions, claimTtlMs: 200, afterFinalLockAssertion: async () => { checked(); await paused; }, ownerLiveness }); + const replacement = await WakeAcceptanceStore.open(root, { ...testStoreOptions, claimTtlMs: 200, ownerLiveness }); + const oldTerminal = old.transitionClaimed(accepted.record.acceptance_id, first.claim, "completed"); + await reached; + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.equal((await replacement.acquireClaim(accepted.record.acceptance_id, "44444444-4444-4444-8444-444444444444")).state, "held"); + firstOwnerLive = false; + const second = await replacement.acquireClaim(accepted.record.acceptance_id, "44444444-4444-4444-8444-444444444444"); + if (second.state !== "acquired") throw new Error("takeover claim missing"); + await replacement.transitionClaimed(accepted.record.acceptance_id, second.claim, "running"); + resume(); + await assert.rejects(oldTerminal, /claim was lost/); + await replacement.transitionClaimed(accepted.record.acceptance_id, second.claim, "failed", "engine_failed"); + await old.close(); + await replacement.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("a mismatched PID namespace blocks transition-lock reclamation", async () => { + const root = await privateRoot(); + try { + const setup = await WakeAcceptanceStore.open(root, testStoreOptions); + const accepted = await setup.accept(parseWakeAcceptanceRequest(request("namespace-lock"))); + const first = await setup.acquireClaim(accepted.record.acceptance_id, "55555555-5555-4555-8555-555555555555"); + if (first.state !== "acquired") throw new Error("initial claim missing"); + await setup.transitionClaimed(accepted.record.acceptance_id, first.claim, "running"); + await setup.close(); + + let continueCommit!: () => void; + const held = new Promise((resolve) => { continueCommit = resolve; }); + let locked!: () => void; + const reached = new Promise((resolve) => { locked = resolve; }); + const owner = await WakeAcceptanceStore.open(root, { ...testStoreOptions, afterFinalLockAssertion: async () => { locked(); await held; } }); + const replacement = await WakeAcceptanceStore.open(root, { + processIdentity: async () => ({ pid: 2, process_start: "replacement-start", boot_id: "test-boot", pid_namespace_dev: 2, pid_namespace_ino: 2 }), + ownerLiveness: async () => false + }); + const completing = owner.transitionClaimed(accepted.record.acceptance_id, first.claim, "completed"); + await reached; + await assert.rejects(replacement.transitionClaimed(accepted.record.acceptance_id, first.claim, "completed"), (error: unknown) => error instanceof WakeTransitionLockBlockedError && error.code === "offline_reconciliation_required"); + continueCommit(); + await completing; + await owner.close(); + await replacement.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("strict v2 parser rejects hidden fields, loose time, and oversized text", () => { + assert.throws(() => parseWakeAcceptanceRequest({ ...request(), extra: true }), /exactly/); + assert.throws(() => parseWakeAcceptanceRequest({ ...request(), event: { ...request().event, occurred_at: "2026-08-17T00:00:00Z" } }), /RFC3339/); + assert.throws(() => parseWakeAcceptanceRequest(request("large", "x".repeat(16_385))), /bounded string/); +}); + +test("production transition locking fails closed outside Linux", async (context) => { + if (process.platform === "linux") return context.skip("Linux has the required process identity authority"); + const root = await privateRoot(); + try { await assert.rejects(WakeAcceptanceStore.open(root), /require Linux process identity/); } finally { await rm(root, { recursive: true, force: true }); } +}); + +class FakeCoreHost implements Pick { + readonly wakes: OrganizationRuntimeWakeRequest[] = []; + private releaseTurn: (() => void) | undefined; + async start(): Promise {} + async wake(request_: OrganizationRuntimeWakeRequest) { + this.wakes.push(request_); + await new Promise((resolve) => { this.releaseTurn = resolve; }); + return { version: "noopolis.daimon.wake-result.v1", status: "completed", agentId: request_.agentId, wakeId: request_.event.id, text: "private", durationMs: 1 } as const; + } + async health(_agentId?: string) { return { version: "noopolis.daimon.organization-runtime-health.v1" as const, state: "running" as const, agents: [{ agentId: "alpha", state: "idle" as const }] }; } + async activity() { return { version: "noopolis.daimon.organization-runtime-activity.v1" as const, items: [] }; } + async stop() { this.release(); return { version: "noopolis.daimon.organization-runtime-stop.v1" as const, state: "stopped" as const }; } + release(): void { this.releaseTurn?.(); } +} + +async function privateRoot(): Promise { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-acceptance-")); await chmod(root, 0o700); return root; } +async function waitFor(predicate: () => boolean | Promise): Promise { + const deadline = Date.now() + 5_000; + do { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } while (Date.now() < deadline); + throw new Error("timed out"); +} diff --git a/src/runtime/wakeAcceptanceReconciliation.test.ts b/src/runtime/wakeAcceptanceReconciliation.test.ts new file mode 100644 index 0000000..a901071 --- /dev/null +++ b/src/runtime/wakeAcceptanceReconciliation.test.ts @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmod, lstat, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { reconcileOfflineWakeTransition, type OfflineTransitionReconciliationRequest } from "./wakeAcceptanceReconciliation.js"; +import { WakeAcceptanceStore } from "./wakeAcceptanceStore.js"; +import { hostRegistrationDigest } from "./storeCoordination.js"; +import { parseWakeAcceptanceRequest } from "./wakeAcceptanceTypes.js"; + +const agent = "alpha"; +const testStoreOptions = { processIdentity: async () => ({ pid: 1, process_start: "test-start", boot_id: "test-boot", pid_namespace_dev: 1, pid_namespace_ino: 1 }), ownerLiveness: async () => true }; +const testLeaseOptions = { leaseIdentity: async () => ({ owner_id: "77777777-7777-4777-8777-777777777777", pid: 1, process_start: "admin-start", boot_id: "admin-boot", pid_namespace_dev: 1, pid_namespace_ino: 1 }), leaseLiveness: async () => true, hostLiveness: async () => false }; +const wake = (delivery = "offline") => ({ token: undefined, agent_id: agent, delivery_id: delivery, event: { version: "noopolis.daimon.wake.v2" as const, kind: "manual" as const, text: "private", occurred_at: "2026-08-17T00:00:00.000Z" } }); + +test("offline reconciliation atomically fences an attested exact stale lock and is redacted-idempotent", async () => { + const root = await privateRoot(); + try { + const held = await heldLock(root, "exact"); + const request = await reconciliationRequest(root, "exact"); + const result = await reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context, attestation) => { assert.equal(attestation.state, "absent"); return { request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }; } }); + assert.equal(result.state, "reconciled"); + if (result.state !== "reconciled") throw new Error("expected reconciliation"); + const replay = await reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async () => { throw new Error("replay must not inspect proof"); } }); + assert.deepEqual(replay, result); + const changedProof = await reconcileOfflineWakeTransition({ ...request, attestation: { ...request.attestation, proof: "different" } }, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => ({ request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }) }); + assert.deepEqual(changedProof, { version: "noopolis.daimon.offline-transition-reconciliation.v1", state: "blocked", code: "offline_reconciliation_required" }); + const receipts = (await readFile(path.join(root, receiptName(request)), "utf8")); + assert.equal(receipts.includes("proof"), false); + held.resume(); + await assert.rejects(held.completing, /claim was lost/); + await closeAbandonedStore(held.store); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("offline reconciliation blocks untrusted proof, identity mismatch, and concurrent administration", async () => { + const root = await privateRoot(); + try { + const held = await heldLock(root, "blocked"); + const request = await reconciliationRequest(root, "blocked"); + const denied = await reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async () => { throw new Error("not attested"); } }); + assert.equal(denied.state, "blocked"); + const wrongContext = await reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => ({ request_digest: "0".repeat(64), nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }) }); + assert.equal(wrongContext.state, "blocked"); + const mismatch = await reconcileOfflineWakeTransition({ ...request, lock: { ...request.lock, ino: request.lock.ino + 1 } }, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => ({ request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }) }); + assert.equal(mismatch.state, "blocked"); + let release!: () => void; + const paused = new Promise((resolve) => { release = resolve; }); + const first = reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => { await paused; return { request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }; } }); + await new Promise((resolve) => setTimeout(resolve, 5)); + await assert.rejects(WakeAcceptanceStore.open(root, testStoreOptions), /reserved for offline reconciliation/); + const concurrent = await reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => ({ request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }) }); + assert.equal(concurrent.state, "blocked"); + release(); + assert.equal((await first).state, "reconciled"); + held.resume(); + await assert.rejects(held.completing, /claim was lost/); + await closeAbandonedStore(held.store); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("cross-namespace registrations require exact deployment-authorized identities", async () => { + const root = await privateRoot(); + const crossNamespace = { ...testStoreOptions, processIdentity: async () => ({ pid: 2, process_start: "old-container", boot_id: "old-boot", pid_namespace_dev: 2, pid_namespace_ino: 2 }) }; + try { + const held = await heldLock(root, "cross", crossNamespace); + const request = await reconciliationRequest(root, "cross"); + const unlisted = await reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => ({ request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }) }); + assert.equal(unlisted.state, "blocked"); + const recovered = await reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => { + assert.equal(context.stale_host_registrations.length, 1); + return { request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: context.stale_host_registrations.map(hostRegistrationDigest) }; + } }); + assert.equal(recovered.state, "reconciled"); + if (recovered.state !== "reconciled") throw new Error("expected reconciliation"); + assert.equal(recovered.cleared_registration_digests.length, 1); + held.resume(); + await assert.rejects(held.completing, /claim was lost/); + await closeAbandonedStore(held.store); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("mutated attested registration blocks offline reconciliation", async () => { + const root = await privateRoot(); + const crossNamespace = { ...testStoreOptions, processIdentity: async () => ({ pid: 2, process_start: "old-container", boot_id: "old-boot", pid_namespace_dev: 2, pid_namespace_ino: 2 }) }; + try { + const held = await heldLock(root, "mutated", crossNamespace); + const request = await reconciliationRequest(root, "mutated"); + const result = await reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => { + const host = context.stale_host_registrations[0]; + if (host === undefined) throw new Error("missing host registration"); + const { dev: _dev, ino: _ino, ...record } = host; + await writeFile(path.join(root, `.host-online-${host.owner_id}.json`), JSON.stringify({ ...record, process_start: "changed" })); + return { request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [hostRegistrationDigest(host)] }; + } }); + assert.equal(result.state, "blocked"); + held.resume(); + await held.completing; + await closeAbandonedStore(held.store); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("a prepared receipt resumes after a reconciliation crash window", async () => { + const root = await privateRoot(); + try { + const held = await heldLock(root, "retry"); + const request = await reconciliationRequest(root, "retry"); + const receipt = path.join(root, receiptName(request)); + await writeFile(receipt, JSON.stringify({ version: "noopolis.daimon.offline-transition-reconciliation-receipt.v1", reconciliation_id: "99999999-9999-4999-8999-999999999999", request_digest: createHash("sha256").update(JSON.stringify(request)).digest("hex"), state: "prepared", reconciled_at: "", cleared_registration_digests: [] }), { mode: 0o600 }); + await chmod(receipt, 0o600); + const result = await reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => ({ request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }) }); + assert.equal(result.state, "reconciled"); + held.resume(); + await assert.rejects(held.completing, /claim was lost/); + await closeAbandonedStore(held.store); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("an admin lease scan preserves a host registered before it", async () => { + const root = await privateRoot(); + try { + const host = await WakeAcceptanceStore.open(root, testStoreOptions); + const before = await hostRegistrations(root); + const result = await reconcileOfflineWakeTransition(await emptyReconciliationRequest(root), { storePath: root, ...testLeaseOptions, hostLiveness: async () => true, verifyDeploymentAttestation: async (context) => ({ request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }) }); + assert.equal(result.state, "blocked"); + assert.deepEqual(await hostRegistrations(root), before); + await host.close(); + assert.deepEqual(await hostRegistrations(root), []); + assert.equal((await readdir(root)).includes(".offline-reconciliation.lock"), false); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("a host aborts and cleans its registration when admin leases after its initial check", async () => { + const root = await privateRoot(); + let initialPassed!: () => void; + let releaseHost!: () => void; + const initial = new Promise((resolve) => { initialPassed = resolve; }); + const resumeHost = new Promise((resolve) => { releaseHost = resolve; }); + let leaseCreated!: () => void; + let releaseAdmin!: () => void; + const leased = new Promise((resolve) => { leaseCreated = resolve; }); + const resumeAdmin = new Promise((resolve) => { releaseAdmin = resolve; }); + try { + const openingOptions = { ...testStoreOptions, afterInitialLeaseCheckForTest: async () => { initialPassed(); await resumeHost; } }; + const opening = WakeAcceptanceStore.open(root, openingOptions); + await initial; + const admin = reconcileOfflineWakeTransition(await emptyReconciliationRequest(root), { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => { leaseCreated(); await resumeAdmin; return { request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }; } }); + await leased; + releaseHost(); + await assert.rejects(opening, /reserved for offline reconciliation/); + assert.deepEqual(await hostRegistrations(root), []); + releaseAdmin(); + assert.equal((await admin).state, "blocked"); + assert.deepEqual(await hostRegistrations(root), []); + assert.equal((await readdir(root)).includes(".offline-reconciliation.lock"), false); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +async function heldLock(root: string, delivery: string, ownerOptions = testStoreOptions) { + const store = await WakeAcceptanceStore.open(root, testStoreOptions); + const accepted = await store.accept(parseWakeAcceptanceRequest(wake(delivery))); + const claim = await store.acquireClaim(accepted.record.acceptance_id, "66666666-6666-4666-8666-666666666666"); + if (claim.state !== "acquired") throw new Error("claim missing"); + await store.transitionClaimed(accepted.record.acceptance_id, claim.claim, "running"); + let resume!: () => void; + const paused = new Promise((resolve) => { resume = resolve; }); + let reached!: () => void; + const locked = new Promise((resolve) => { reached = resolve; }); + const owner = await WakeAcceptanceStore.open(root, { ...ownerOptions, afterFinalLockAssertion: async () => { reached(); await paused; } }); + const completing = owner.transitionClaimed(accepted.record.acceptance_id, claim.claim, "completed"); + await locked; + await store.close(); + return { store: owner, resume, completing }; +} + +async function reconciliationRequest(root: string, delivery: string): Promise { + const file = `${createHash("sha256").update(`${agent}\u0000${delivery}`).digest("hex")}.transition-lock`; + const [store, lock, raw] = await Promise.all([lstat(root), lstat(path.join(root, file)), readFile(path.join(root, file), "utf8")]); + const identity = JSON.parse(raw) as Record; + return { + version: "noopolis.daimon.offline-transition-reconciliation.v1", agent_id: agent, delivery_id: delivery, + store: { dev: Number(store.dev), ino: Number(store.ino) }, + lock: { dev: Number(lock.dev), ino: Number(lock.ino), owner_id: identity.owner_id as string, generation: identity.generation as string, pid: identity.pid as number, process_start: identity.process_start as string, boot_id: identity.boot_id as string, pid_namespace_dev: identity.pid_namespace_dev as number, pid_namespace_ino: identity.pid_namespace_ino as number }, + attestation: { version: "noopolis.deployment.container-quiescence.v1", authority_id: "deployment", attestation_id: "attestation", state: "absent", proof: "opaque-attestation" } + }; +} + +async function emptyReconciliationRequest(root: string): Promise { + const store = await lstat(root); + return { version: "noopolis.daimon.offline-transition-reconciliation.v1", agent_id: agent, delivery_id: "empty", store: { dev: Number(store.dev), ino: Number(store.ino) }, lock: { dev: 1, ino: 1, owner_id: "88888888-8888-4888-8888-888888888888", generation: "99999999-9999-4999-8999-999999999999", pid: 1, process_start: "old", boot_id: "old", pid_namespace_dev: 1, pid_namespace_ino: 1 }, attestation: { version: "noopolis.deployment.container-quiescence.v1", authority_id: "deployment", attestation_id: "empty-attestation", state: "absent", proof: "opaque-attestation" } }; +} + +function receiptName(request: OfflineTransitionReconciliationRequest): string { return `.reconcile-${createHash("sha256").update(JSON.stringify({ store: request.store, lock: request.lock })).digest("hex")}.json`; } +async function hostRegistrations(root: string): Promise { return (await readdir(root)).filter((entry) => entry.startsWith(".host-online-")).sort(); } +async function closeAbandonedStore(store: WakeAcceptanceStore): Promise { await (store as unknown as { directory: { close(): Promise } }).directory.close(); } +async function privateRoot(): Promise { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-reconcile-")); await chmod(root, 0o700); return root; } diff --git a/src/runtime/wakeAcceptanceReconciliation.ts b/src/runtime/wakeAcceptanceReconciliation.ts new file mode 100644 index 0000000..7cfc622 --- /dev/null +++ b/src/runtime/wakeAcceptanceReconciliation.ts @@ -0,0 +1,139 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { link, lstat, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises"; +import path from "node:path"; +import { clearDeadHostRegistrations, hostRegistrationDigest, listHostRegistrations, type StoreHostRegistration, type StoreHostRegistrationIdentity } from "./storeCoordination.js"; + +export const OFFLINE_RECONCILIATION_VERSION = "noopolis.daimon.offline-transition-reconciliation.v1" as const; +export const OFFLINE_RECONCILIATION_BLOCKED_CODE = "offline_reconciliation_required" as const; +const MAX_BYTES = 16_384; +const RECEIPT_VERSION = "noopolis.daimon.offline-transition-reconciliation-receipt.v1" as const; + +export const OFFLINE_RECONCILIATION_REQUEST_SCHEMA = { + $schema: "https://json-schema.org/draft/2020-12/schema", $id: OFFLINE_RECONCILIATION_VERSION, type: "object", additionalProperties: false, + required: ["version", "agent_id", "delivery_id", "store", "lock", "attestation"], properties: { + version: { const: OFFLINE_RECONCILIATION_VERSION }, agent_id: { type: "string", minLength: 1, maxLength: MAX_BYTES }, delivery_id: { type: "string", minLength: 1, maxLength: MAX_BYTES }, + store: { type: "object", additionalProperties: false, required: ["dev", "ino"], properties: { dev: { type: "integer", minimum: 1 }, ino: { type: "integer", minimum: 1 } } }, + lock: { type: "object", additionalProperties: false, required: ["dev", "ino", "owner_id", "generation", "pid", "process_start", "boot_id", "pid_namespace_dev", "pid_namespace_ino"], properties: { dev: { type: "integer", minimum: 1 }, ino: { type: "integer", minimum: 1 }, owner_id: { type: "string" }, generation: { type: "string" }, pid: { type: "integer", minimum: 1 }, process_start: { type: "string" }, boot_id: { type: "string" }, pid_namespace_dev: { type: "integer", minimum: 1 }, pid_namespace_ino: { type: "integer", minimum: 1 } } }, + attestation: { type: "object", additionalProperties: false, required: ["version", "authority_id", "attestation_id", "state", "proof"], properties: { version: { const: "noopolis.deployment.container-quiescence.v1" }, authority_id: { type: "string", minLength: 1, maxLength: MAX_BYTES }, attestation_id: { type: "string", minLength: 1, maxLength: MAX_BYTES }, state: { enum: ["absent", "quiescent"] }, proof: { type: "string", minLength: 1, maxLength: MAX_BYTES } } } + } +} as const; + +type Identity = Readonly<{ dev: number; ino: number }>; +type Lock = Readonly<{ dev: number; ino: number; owner_id: string; generation: string; pid: number; process_start: string; boot_id: string; pid_namespace_dev: number; pid_namespace_ino: number }>; +type Lease = Readonly<{ owner_id: string; pid: number; process_start: string; boot_id: string; pid_namespace_dev: number; pid_namespace_ino: number }>; +export type OfflineDeploymentAttestation = Readonly<{ version: "noopolis.deployment.container-quiescence.v1"; authority_id: string; attestation_id: string; state: "absent" | "quiescent"; proof: string }>; +export type OfflineTransitionReconciliationRequest = Readonly<{ version: typeof OFFLINE_RECONCILIATION_VERSION; agent_id: string; delivery_id: string; store: Identity; lock: Lock; attestation: OfflineDeploymentAttestation }>; +export type OfflineTransitionReconciliationAuthorizationContext = Readonly<{ version: "noopolis.daimon.offline-transition-authorization-context.v1"; deployment_identity: string; deployment_state: "absent" | "quiescent"; nonce: string; request_digest: string; agent_id: string; delivery_id: string; store: Identity; lock: Lock; stale_host_registrations: readonly StoreHostRegistrationIdentity[] }>; +export type OfflineTransitionReconciliationProofReceipt = Readonly<{ request_digest: string; nonce: string; exclusive_store: true; authorized_registration_digests: readonly string[] }>; +export type OfflineTransitionReconciliationReceipt = Readonly<{ version: typeof RECEIPT_VERSION; reconciliation_id: string; request_digest: string; state: "reconciled"; reconciled_at: string; cleared_registration_digests: readonly string[] }>; +export type OfflineTransitionReconciliationResult = OfflineTransitionReconciliationReceipt | Readonly<{ version: typeof OFFLINE_RECONCILIATION_VERSION; state: "blocked"; code: typeof OFFLINE_RECONCILIATION_BLOCKED_CODE }>; +export type OfflineTransitionReconciliationOptions = Readonly<{ storePath: string; verifyDeploymentAttestation: (context: OfflineTransitionReconciliationAuthorizationContext, attestation: OfflineDeploymentAttestation) => Promise; leaseIdentity?: () => Promise; leaseLiveness?: (lease: Lease) => Promise; hostLiveness?: (host: StoreHostRegistration) => Promise }>; + +/** Deployment-only: caller must exclusively mount the private store offline. */ +export async function reconcileOfflineWakeTransition(value: unknown, options: OfflineTransitionReconciliationOptions): Promise { + try { + const request = parseOfflineTransitionReconciliationRequest(value); + const root = await openStore(options.storePath, request.store); + try { + const lease = await acquireLease(root.path, root.directory, await (options.leaseIdentity ?? currentLeaseIdentity)(), options.leaseLiveness ?? leaseIsAlive); + if (lease === undefined) return blocked(); + try { + const target = lockPath(root.path, request.agent_id, request.delivery_id); + const receiptPath = receiptFile(root.path, request); + let receipt = await readReceiptOptional(receiptPath); + if (receipt !== undefined && receipt.request_digest !== digest(request)) return blocked(); + if (receipt?.state === "reconciled") return publicReceipt(receipt); + const context = authorizationContext(request, await listHostRegistrations(root.path)); + const proof = await options.verifyDeploymentAttestation(context, request.attestation).catch(() => { throw new OfflineTransitionReconciliationBlockedError(); }); + if (!sameProofReceipt(proof, context)) throw new OfflineTransitionReconciliationBlockedError(); + const cleared = await clearDeadHostRegistrations(root.path, root.directory, lease, options.hostLiveness ?? leaseIsAlive, new Set(proof.authorized_registration_digests)).catch(() => undefined); + if (cleared === undefined) return blocked(); + await assertStore(root, request.store); + await assertRecord(recordPath(root.path, request.agent_id, request.delivery_id), request); + if (receipt === undefined) receipt = await writeReceipt(receiptPath, { version: RECEIPT_VERSION, reconciliation_id: randomUUID(), request_digest: digest(request), state: "prepared", reconciled_at: "", cleared_registration_digests: [] }, root.directory); + const lock = await readLockOptional(target); + if (lock !== undefined) { + if (!same(lock, request.lock) || !same(await lstat(target), request.lock)) throw new OfflineTransitionReconciliationBlockedError(); + await assertStore(root, request.store); + await assertRecord(recordPath(root.path, request.agent_id, request.delivery_id), request); + await unlink(target); + await root.directory.sync(); + } + const reconciled = { ...receipt, state: "reconciled" as const, reconciled_at: new Date().toISOString(), cleared_registration_digests: uniqueDigests([...receipt.cleared_registration_digests, ...cleared]) }; + await replaceReceipt(receiptPath, reconciled, root.directory); + return publicReceipt(reconciled); + } finally { await releaseLease(lease, root.directory, root.path); } + } finally { await root.directory.close(); } + } catch (error) { + if (error instanceof OfflineTransitionReconciliationBlockedError) return blocked(); + throw error; + } +} + +export function parseOfflineTransitionReconciliationRequest(value: unknown): OfflineTransitionReconciliationRequest { + const root = strictRecord(value, ["version", "agent_id", "delivery_id", "store", "lock", "attestation"]); + if (text(root.version) !== OFFLINE_RECONCILIATION_VERSION) throw new TypeError("offline reconciliation version is unsupported"); + const store = strictRecord(root.store, ["dev", "ino"]); + const lock = strictRecord(root.lock, ["dev", "ino", "owner_id", "generation", "pid", "process_start", "boot_id", "pid_namespace_dev", "pid_namespace_ino"]); + const attestation = strictRecord(root.attestation, ["version", "authority_id", "attestation_id", "state", "proof"]); + const state = text(attestation.state); + if (text(attestation.version) !== "noopolis.deployment.container-quiescence.v1" || (state !== "absent" && state !== "quiescent")) throw new TypeError("offline reconciliation attestation is invalid"); + const parsedLock: Lock = { dev: positive(lock.dev), ino: positive(lock.ino), owner_id: uuid(lock.owner_id), generation: uuid(lock.generation), pid: positive(lock.pid), process_start: nonBlank(lock.process_start), boot_id: nonBlank(lock.boot_id), pid_namespace_dev: positive(lock.pid_namespace_dev), pid_namespace_ino: positive(lock.pid_namespace_ino) }; + return { version: OFFLINE_RECONCILIATION_VERSION, agent_id: nonBlank(root.agent_id), delivery_id: nonBlank(root.delivery_id), store: { dev: positive(store.dev), ino: positive(store.ino) }, lock: parsedLock, attestation: { version: "noopolis.deployment.container-quiescence.v1", authority_id: nonBlank(attestation.authority_id), attestation_id: nonBlank(attestation.attestation_id), state, proof: nonBlank(attestation.proof) } }; +} + +export class OfflineTransitionReconciliationBlockedError extends Error { readonly code = OFFLINE_RECONCILIATION_BLOCKED_CODE; constructor() { super("offline transition reconciliation is blocked"); } } +/** Normal hosts may start only after the durable offline lease is absent. */ +export async function assertOfflineReconciliationLeaseAvailable(root: string): Promise { if (await readLeaseOptional(path.join(root, ".offline-reconciliation.lock")) !== undefined) throw new OfflineTransitionReconciliationBlockedError(); } + +async function openStore(root: string, expected: Identity): Promise<{ path: string; directory: Awaited> }> { + if (!path.isAbsolute(root)) throw new OfflineTransitionReconciliationBlockedError(); + await assertNoLinks(root); + const before = await lstat(root); + if (!before.isDirectory() || before.isSymbolicLink() || before.uid !== process.getuid?.() || (before.mode & 0o777) !== 0o700 || !same(before, expected)) throw new OfflineTransitionReconciliationBlockedError(); + const directory = await open(root, constants.O_RDONLY | directoryFlag() | noFollow()); + try { + const canonical = await realpath(root); + if (!same(await directory.stat(), expected)) throw new OfflineTransitionReconciliationBlockedError(); + return { path: canonical, directory }; + } catch (error) { await directory.close().catch(() => undefined); throw error; } +} +async function assertStore(root: { path: string; directory: Awaited> }, expected: Identity): Promise { await assertNoLinks(root.path); if (!same(await lstat(root.path), expected) || !same(await root.directory.stat(), expected) || await realpath(root.path) !== root.path) throw new OfflineTransitionReconciliationBlockedError(); } +async function acquireLease(root: string, directory: Awaited>, owner: Lease, liveness: (lease: Lease) => Promise): Promise { const target = path.join(root, ".offline-reconciliation.lock"); const current = await readLeaseOptional(target); if (current !== undefined) { if (!sameNamespace(current, owner) || await liveness(current)) return undefined; await unlink(target); await directory.sync(); } try { await writeNew(target, owner); await directory.sync(); return owner; } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined; throw error; } } +async function releaseLease(lease: Lease, directory: Awaited>, root: string): Promise { const target = path.join(root, ".offline-reconciliation.lock"); const current = await readLease(target); if (!sameLease(current, lease)) throw new OfflineTransitionReconciliationBlockedError(); await unlink(target); await directory.sync(); } +function lockPath(root: string, agent: string, delivery: string): string { return path.join(root, `${createHash("sha256").update(`${agent}\u0000${delivery}`).digest("hex")}.transition-lock`); } +function recordPath(root: string, agent: string, delivery: string): string { return path.join(root, `${createHash("sha256").update(`${agent}\u0000${delivery}`).digest("hex")}.json`); } +function receiptFile(root: string, request: OfflineTransitionReconciliationRequest): string { return path.join(root, `.reconcile-${createHash("sha256").update(JSON.stringify({ store: request.store, lock: request.lock })).digest("hex")}.json`); } +function digest(request: unknown): string { return createHash("sha256").update(JSON.stringify(request)).digest("hex"); } +function authorizationContext(request: OfflineTransitionReconciliationRequest, stale_host_registrations: readonly StoreHostRegistrationIdentity[]): OfflineTransitionReconciliationAuthorizationContext { return { version: "noopolis.daimon.offline-transition-authorization-context.v1", deployment_identity: request.attestation.authority_id, deployment_state: request.attestation.state, nonce: request.attestation.attestation_id, request_digest: digest({ request, stale_host_registrations }), agent_id: request.agent_id, delivery_id: request.delivery_id, store: request.store, lock: request.lock, stale_host_registrations }; } +function sameProofReceipt(value: unknown, context: OfflineTransitionReconciliationAuthorizationContext): value is OfflineTransitionReconciliationProofReceipt { try { const proof = strictRecord(value, ["request_digest", "nonce", "exclusive_store", "authorized_registration_digests"]); return text(proof.request_digest) === context.request_digest && text(proof.nonce) === context.nonce && proof.exclusive_store === true && Array.isArray(proof.authorized_registration_digests) && proof.authorized_registration_digests.every((item) => /^[a-f0-9]{64}$/u.test(text(item))) && proof.authorized_registration_digests.every((item) => context.stale_host_registrations.some((host) => hostRegistrationDigest(host) === item)); } catch { return false; } } +async function readLock(file: string): Promise { const entry = await lstat(file); if (!safeFile(entry)) throw new OfflineTransitionReconciliationBlockedError(); const value = strictRecord(JSON.parse((await readFile(file)).toString("utf8")), ["owner_id", "generation", "pid", "process_start", "boot_id", "pid_namespace_dev", "pid_namespace_ino"]); return { dev: Number(entry.dev), ino: Number(entry.ino), owner_id: uuid(value.owner_id), generation: uuid(value.generation), pid: positive(value.pid), process_start: nonBlank(value.process_start), boot_id: nonBlank(value.boot_id), pid_namespace_dev: positive(value.pid_namespace_dev), pid_namespace_ino: positive(value.pid_namespace_ino) }; } +async function readLease(file: string): Promise { const entry = await lstat(file); if (!safeFile(entry)) throw new OfflineTransitionReconciliationBlockedError(); const value = strictRecord(JSON.parse((await readFile(file)).toString("utf8")), ["owner_id", "pid", "process_start", "boot_id", "pid_namespace_dev", "pid_namespace_ino"]); return { owner_id: uuid(value.owner_id), pid: positive(value.pid), process_start: nonBlank(value.process_start), boot_id: nonBlank(value.boot_id), pid_namespace_dev: positive(value.pid_namespace_dev), pid_namespace_ino: positive(value.pid_namespace_ino) }; } +async function readLeaseOptional(file: string): Promise { try { return await readLease(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } +async function readLockOptional(file: string): Promise { try { return await readLock(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } +async function assertRecord(file: string, request: OfflineTransitionReconciliationRequest): Promise { const entry = await lstat(file).catch(() => { throw new OfflineTransitionReconciliationBlockedError(); }); if (!safeFile(entry)) throw new OfflineTransitionReconciliationBlockedError(); const value: unknown = JSON.parse((await readFile(file)).toString("utf8")); if (value === null || typeof value !== "object" || Array.isArray(value)) throw new OfflineTransitionReconciliationBlockedError(); const record = value as Record; if (record.agent_id !== request.agent_id || record.delivery_id !== request.delivery_id) throw new OfflineTransitionReconciliationBlockedError(); } +type StoredReceipt = OfflineTransitionReconciliationReceipt | Readonly<{ version: typeof RECEIPT_VERSION; reconciliation_id: string; request_digest: string; state: "prepared"; reconciled_at: ""; cleared_registration_digests: readonly string[] }>; +async function readReceiptOptional(file: string): Promise { try { const entry = await lstat(file); if (!safeFile(entry)) throw new OfflineTransitionReconciliationBlockedError(); const value = strictRecord(JSON.parse((await readFile(file)).toString("utf8")), ["version", "reconciliation_id", "request_digest", "state", "reconciled_at", "cleared_registration_digests"]); const state = text(value.state); const cleared_registration_digests = digests(value.cleared_registration_digests); if (text(value.version) !== RECEIPT_VERSION || (state !== "prepared" && state !== "reconciled") || !/^[a-f0-9]{64}$/u.test(text(value.request_digest))) throw new OfflineTransitionReconciliationBlockedError(); const receipt = { version: RECEIPT_VERSION, reconciliation_id: uuid(value.reconciliation_id), request_digest: text(value.request_digest), state, reconciled_at: text(value.reconciled_at), cleared_registration_digests }; if (state === "prepared" && receipt.reconciled_at) throw new OfflineTransitionReconciliationBlockedError(); if (state === "reconciled" && !receipt.reconciled_at) throw new OfflineTransitionReconciliationBlockedError(); return receipt as StoredReceipt; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } +async function writeReceipt(target: string, receipt: StoredReceipt, directory: Awaited>): Promise { const temp = `${target}.${randomUUID()}`; try { await writeNew(temp, receipt); await link(temp, target); await directory.sync(); return receipt; } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new OfflineTransitionReconciliationBlockedError(); throw error; } finally { await unlink(temp).catch(() => undefined); } } +async function replaceReceipt(target: string, receipt: OfflineTransitionReconciliationReceipt, directory: Awaited>): Promise { const temp = `${target}.${randomUUID()}`; try { await writeNew(temp, receipt); await rename(temp, target); await directory.sync(); } finally { await unlink(temp).catch(() => undefined); } } +async function writeNew(file: string, value: unknown): Promise { const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow(), 0o600); try { await handle.writeFile(JSON.stringify(value)); await handle.sync(); } finally { await handle.close(); } } +function publicReceipt(receipt: OfflineTransitionReconciliationReceipt): OfflineTransitionReconciliationReceipt { return receipt; } +function blocked(): OfflineTransitionReconciliationResult { return { version: OFFLINE_RECONCILIATION_VERSION, state: "blocked", code: OFFLINE_RECONCILIATION_BLOCKED_CODE }; } +function strictRecord(value: unknown, keys: readonly string[]): Record { if (value === null || typeof value !== "object" || Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) throw new TypeError("offline reconciliation request is invalid"); const result: Record = Object.create(null); for (const key of Reflect.ownKeys(value)) { if (typeof key !== "string") throw new TypeError("offline reconciliation request is invalid"); const descriptor = Object.getOwnPropertyDescriptor(value, key); if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) throw new TypeError("offline reconciliation request is invalid"); result[key] = descriptor.value; } if (Object.keys(result).length !== keys.length || keys.some((key) => !Object.hasOwn(result, key))) throw new TypeError("offline reconciliation request is invalid"); return result; } +function text(value: unknown): string { if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > MAX_BYTES) throw new TypeError("offline reconciliation request is invalid"); return value; } +function nonBlank(value: unknown): string { const result = text(value); if (!result.trim()) throw new TypeError("offline reconciliation request is invalid"); return result; } +function uuid(value: unknown): string { const result = nonBlank(value); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(result)) throw new TypeError("offline reconciliation request is invalid"); return result; } +function positive(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) throw new TypeError("offline reconciliation request is invalid"); return value; } +function digests(value: unknown): readonly string[] { if (!Array.isArray(value) || value.length > 128 || value.some((item) => !/^[a-f0-9]{64}$/u.test(text(item)))) throw new OfflineTransitionReconciliationBlockedError(); return uniqueDigests(value); } +function uniqueDigests(values: readonly string[]): readonly string[] { return [...new Set(values)].sort(); } +function same(left: Pick, right: Pick): boolean { return Number(left.dev) === right.dev && Number(left.ino) === right.ino; } +function sameNamespace(left: Pick, right: Pick): boolean { return left.pid_namespace_dev === right.pid_namespace_dev && left.pid_namespace_ino === right.pid_namespace_ino; } +function sameLease(left: Lease, right: Lease): boolean { return left.owner_id === right.owner_id && left.pid === right.pid && left.process_start === right.process_start && left.boot_id === right.boot_id && sameNamespace(left, right); } +function safeFile(entry: Awaited>): boolean { return entry.isFile() && !entry.isSymbolicLink() && Number(entry.uid) === process.getuid?.() && (Number(entry.mode) & 0o777) === 0o600 && Number(entry.size) <= MAX_BYTES; } +function noFollow(): number { return (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; } +function directoryFlag(): number { return (constants as typeof constants & { O_DIRECTORY?: number }).O_DIRECTORY ?? 0; } +async function assertNoLinks(target: string): Promise { let current = path.parse(target).root; for (const part of path.relative(current, target).split(path.sep).filter(Boolean)) { current = path.join(current, part); if ((await lstat(current)).isSymbolicLink() && current !== "/var") throw new OfflineTransitionReconciliationBlockedError(); } } +async function currentLeaseIdentity(): Promise { if (process.platform !== "linux") throw new OfflineTransitionReconciliationBlockedError(); const boot_id = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim(); const namespace = await stat("/proc/self/ns/pid"); const process_start = await linuxProcessStart(process.pid); if (!boot_id || !Number.isSafeInteger(Number(namespace.dev)) || !Number.isSafeInteger(Number(namespace.ino))) throw new OfflineTransitionReconciliationBlockedError(); return { owner_id: randomUUID(), pid: process.pid, process_start, boot_id, pid_namespace_dev: Number(namespace.dev), pid_namespace_ino: Number(namespace.ino) }; } +async function leaseIsAlive(lease: Lease): Promise { if (process.platform !== "linux") throw new OfflineTransitionReconciliationBlockedError(); const namespace = await stat("/proc/self/ns/pid"); if (!sameNamespace(lease, { pid_namespace_dev: Number(namespace.dev), pid_namespace_ino: Number(namespace.ino) })) throw new OfflineTransitionReconciliationBlockedError(); const boot = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim(); if (!boot) throw new OfflineTransitionReconciliationBlockedError(); if (boot !== lease.boot_id) return false; try { return await linuxProcessStart(lease.pid) === lease.process_start; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw new OfflineTransitionReconciliationBlockedError(); } } +async function linuxProcessStart(pid: number): Promise { const statText = await readFile(`/proc/${pid}/stat`, "utf8"); const close = statText.lastIndexOf(")"); const start = statText.slice(close + 2).trim().split(/\s+/u)[19]; if (close < 0 || start === undefined || !/^\d+$/u.test(start)) throw new OfflineTransitionReconciliationBlockedError(); return start; } diff --git a/src/runtime/wakeAcceptanceStore.ts b/src/runtime/wakeAcceptanceStore.ts new file mode 100644 index 0000000..8892cb9 --- /dev/null +++ b/src/runtime/wakeAcceptanceStore.ts @@ -0,0 +1,372 @@ +import { constants } from "node:fs"; +import { link, lstat, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; + +import { + MAX_WAKE_ACCEPTANCE_RECORD_BYTES, + WAKE_ACCEPTANCE_VERSION, + WAKE_RECEIPT_STATUS_VERSION, + parseWakeAcceptanceRequest, + type OrganizationRuntimeWakeAcceptance, + type OrganizationRuntimeWakeAcceptanceRequest, + type OrganizationRuntimeWakeReceiptStatus, + type WakeReceiptCode, + type WakeReceiptState, + wakeAcceptanceDigest +} from "./wakeAcceptanceTypes.js"; +import { assertOfflineReconciliationLeaseAvailable } from "./wakeAcceptanceReconciliation.js"; +import { acquireHostRegistration, releaseHostRegistration, type StoreHostRegistration } from "./storeCoordination.js"; + +type Stored = Readonly<{ + acceptance_id: string; + agent_id: string; + delivery_id: string; + request_digest: string; + event: OrganizationRuntimeWakeAcceptanceRequest["event"]; + state: WakeReceiptState; + accepted_at: string; + updated_at: string; + claim_generation?: string; + code?: WakeReceiptCode; +}>; +export type WakeExecutionClaim = Readonly<{ owner_id: string; generation: string; expires_at: string }>; +export type WakeExecutionClaimResult = Readonly<{ state: "acquired"; claim: WakeExecutionClaim }> | Readonly<{ state: "held" | "terminal" }>; +type TransitionLock = Readonly<{ owner_id: string; generation: string; pid: number; process_start: string; boot_id: string; pid_namespace_dev: number; pid_namespace_ino: number }>; +type DirectoryIdentity = Readonly<{ dev: number; ino: number; uid: number; mode: number }>; +export type WakeAcceptanceStoreTestOptions = Readonly<{ claimTtlMs?: number; afterFinalLockAssertion?: () => Promise; ownerLiveness?: (lock: TransitionLock) => Promise; processIdentity?: () => Promise> }>; +/** Deliberately absent from the public option type; adjacent tests synchronize only this race. */ +type InternalTestHooks = Readonly<{ afterInitialLeaseCheckForTest?: () => Promise }>; +const DEFAULT_CLAIM_TTL_MS = 240_000; + +/** Durable, private idempotency authority; callers must pre-create its 0700 root. */ +export class WakeAcceptanceStore { + private mutations: Promise = Promise.resolve(); + private constructor(private readonly root: string, private readonly directory: Awaited>, private readonly identity: DirectoryIdentity, private readonly registration: StoreHostRegistration, private readonly claimTtlMs: number, private readonly owner: Omit, private readonly ownerLiveness: (lock: TransitionLock) => Promise, private readonly afterFinalLockAssertion?: () => Promise) {} + + static async open(root: string, options: WakeAcceptanceStoreTestOptions = {}): Promise { + const claimTtlMs = options.claimTtlMs ?? DEFAULT_CLAIM_TTL_MS; + if (!Number.isInteger(claimTtlMs) || claimTtlMs < 1 || claimTtlMs > 600_000) throw new Error("wake acceptance claim lease is outside its bound"); + if (!path.isAbsolute(root)) throw new Error("wake acceptance store path must be absolute"); + const owner = await (options.processIdentity ?? currentProcessIdentity)(); + await assertNoLinks(root); + await assertOfflineReconciliationLeaseAvailable(root).catch(() => { throw new Error("wake acceptance store is reserved for offline reconciliation"); }); + await (options as WakeAcceptanceStoreTestOptions & InternalTestHooks).afterInitialLeaseCheckForTest?.(); + const before = await lstat(root); + if (!before.isDirectory() || before.isSymbolicLink() || before.uid !== process.getuid?.() || (before.mode & 0o777) !== 0o700) throw new Error("wake acceptance store must be a caller-prepared private directory"); + const directory = await open(root, constants.O_RDONLY | directoryFlag() | noFollow()); + try { + const after = await directory.stat(); + const real = await realpath(root); + if (!same(identity(before), identity(after))) throw new Error("wake acceptance store changed during validation"); + const registration = await acquireHostRegistration(real, directory, owner); + return new WakeAcceptanceStore(real, directory, identity(before), registration, claimTtlMs, owner, options.ownerLiveness ?? processIsAlive, options.afterFinalLockAssertion); + } catch (error) { + await directory.close().catch(() => undefined); + throw error; + } + } + + accept(request: OrganizationRuntimeWakeAcceptanceRequest): Promise<{ readonly record: Stored; readonly created: boolean }> { + return this.serialize(async () => await this.acceptNow(request)); + } + + private async acceptNow(request: OrganizationRuntimeWakeAcceptanceRequest): Promise<{ readonly record: Stored; readonly created: boolean }> { + await this.verify(); + const request_digest = wakeAcceptanceDigest(request); + const target = this.fileFor(request.agent_id, request.delivery_id); + const existing = await this.readOptional(target); + if (existing !== undefined) { + if (existing.request_digest !== request_digest) throw new WakeAcceptanceConflictError(); + return { record: existing, created: false }; + } + const now = new Date().toISOString(); + const record: Stored = { acceptance_id: randomUUID(), agent_id: request.agent_id, delivery_id: request.delivery_id, request_digest, event: request.event, state: "accepted", accepted_at: now, updated_at: now }; + const temporary = path.join(this.root, `.pending-${randomUUID()}`); + try { + await this.writeNew(temporary, record); + try { await link(temporary, target); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const winner = await this.read(target); + if (winner.request_digest !== request_digest) throw new WakeAcceptanceConflictError(); + return { record: winner, created: false }; + } + await this.directory.sync(); + return { record, created: true }; + } finally { + await unlink(temporary).catch(() => undefined); + } + } + + async status(acceptanceId: string): Promise { + const record = await this.findByAcceptanceId(acceptanceId); + return record === undefined ? undefined : publicStatus(record); + } + + async recoverable(agentIds: ReadonlySet): Promise { + const result: Stored[] = []; + for (const file of await this.files()) { + const record = await this.read(path.join(this.root, file)); + if (!agentIds.has(record.agent_id)) throw new Error("wake acceptance store contains an unknown agent authority"); + if (record.state === "accepted" || record.state === "running") result.push(record); + } + return result; + } + + transition(acceptanceId: string, state: WakeReceiptState, code?: WakeReceiptCode): Promise { + return this.serialize(async () => await this.transitionNow(acceptanceId, state, code)); + } + + acquireClaim(acceptanceId: string, ownerId: string): Promise { + return this.serialize(async () => { + if (!uuid(ownerId)) throw new Error("wake execution owner is invalid"); + const record = await this.findByAcceptanceId(acceptanceId); + if (record === undefined) throw new Error("wake acceptance receipt is unavailable"); + if (isTerminal(record.state)) return { state: "terminal" }; + const target = this.claimFor(record); + const current = await this.readClaimOptional(target); + if (current !== undefined && !expired(current)) return { state: "held" }; + const lock = await this.acquireTransitionLock(record, ownerId); + if (lock === undefined) return { state: "held" }; + try { + const checked = await this.readClaimOptional(target); + if (checked !== undefined && !expired(checked)) return { state: "held" }; + if (checked !== undefined) { await unlink(target); await this.directory.sync(); } + const claim: WakeExecutionClaim = { owner_id: ownerId, generation: randomUUID(), expires_at: new Date(Date.now() + this.claimTtlMs).toISOString() }; + const temporary = path.join(this.root, `.claim-${randomUUID()}`); + try { + await this.writeNew(temporary, claim); + try { await link(temporary, target); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return { state: "held" }; throw error; } + await this.directory.sync(); + return { state: "acquired", claim }; + } finally { await unlink(temporary).catch(() => undefined); } + } finally { await this.releaseTransitionLock(record, lock); } + }); + } + + transitionClaimed(acceptanceId: string, claim: WakeExecutionClaim, state: WakeReceiptState, code?: WakeReceiptCode): Promise { + return this.serialize(async () => { + const initial = await this.findByAcceptanceId(acceptanceId); + if (initial === undefined) throw new Error("wake acceptance receipt is unavailable"); + const lock = await this.acquireTransitionLock(initial, claim.owner_id); + if (lock === undefined) throw new WakeExecutionClaimLostError(); + try { + let record = await this.transitionNowClaimed(acceptanceId, claim); + if (isTerminal(record.state)) return record; + if (state !== "running" && record.claim_generation !== claim.generation) throw new WakeExecutionClaimLostError(); + await this.assertTransitionLock(record, lock); + record = await this.transitionNowClaimed(acceptanceId, claim); + if (isTerminal(record.state)) return record; + if (state !== "running" && record.claim_generation !== claim.generation) throw new WakeExecutionClaimLostError(); + await this.assertTransitionLock(record, lock); + await this.afterFinalLockAssertion?.(); + await this.assertTransitionLock(record, lock); + const target = this.fileFor(record.agent_id, record.delivery_id); + const next: Stored = { ...record, state, updated_at: new Date().toISOString(), claim_generation: claim.generation, ...(code === undefined ? {} : { code }) }; + await this.replace(target, next); + return next; + } finally { await this.releaseTransitionLock(initial, lock); } + }); + } + + releaseClaims(ownerId: string): Promise { + return this.serialize(async () => { + for (const file of await this.claimFiles()) { + const claim = await this.readClaim(path.join(this.root, file)); + if (claim.owner_id === ownerId) await unlink(path.join(this.root, file)); + } + await this.directory.sync(); + }); + } + + private async transitionNow(acceptanceId: string, state: WakeReceiptState, code?: WakeReceiptCode): Promise { + const target = await this.pathForAcceptanceId(acceptanceId); + const prior = await this.read(target); + if (isTerminal(prior.state)) return prior; + const record: Stored = { ...prior, state, updated_at: new Date().toISOString(), ...(code === undefined ? {} : { code }) }; + await this.replace(target, record); + return record; + } + + async close(): Promise { await this.mutations; await releaseHostRegistration(this.root, this.directory, this.registration); await this.directory.close(); } + + private serialize(operation: () => Promise): Promise { + const result = this.mutations.catch(() => undefined).then(operation); + this.mutations = result.then(() => undefined, () => undefined); + return result; + } + + private async findByAcceptanceId(acceptanceId: string): Promise { + if (!uuid(acceptanceId)) return undefined; + for (const file of await this.files()) { + const record = await this.read(path.join(this.root, file)); + if (record.acceptance_id === acceptanceId) return record; + } + return undefined; + } + private async pathForAcceptanceId(acceptanceId: string): Promise { + const record = await this.findByAcceptanceId(acceptanceId); + if (record === undefined) throw new Error("wake acceptance receipt is unavailable"); + return this.fileFor(record.agent_id, record.delivery_id); + } + private claimFor(record: Stored): string { return this.fileFor(record.agent_id, record.delivery_id).replace(/\.json$/u, ".claim"); } + private lockFor(record: Stored): string { return this.fileFor(record.agent_id, record.delivery_id).replace(/\.json$/u, ".transition-lock"); } + private async acquireTransitionLock(record: Stored, ownerId: string): Promise { + const target = this.lockFor(record); + const current = await this.readLockOptional(target); + if (current !== undefined && !sameNamespace(current, this.owner)) throw new WakeTransitionLockBlockedError(); + if (current !== undefined && await this.ownerLiveness(current)) return undefined; + if (current !== undefined) { await unlink(target); await this.directory.sync(); } + const lock: TransitionLock = { owner_id: ownerId, generation: randomUUID(), ...this.owner }; + const temporary = path.join(this.root, `.lock-${randomUUID()}`); + try { + await this.writeNew(temporary, lock); + try { await link(temporary, target); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined; throw error; } + await this.directory.sync(); + return lock; + } finally { await unlink(temporary).catch(() => undefined); } + } + private async assertTransitionLock(record: Stored, lock: TransitionLock): Promise { + const current = await this.readLock(this.lockFor(record)).catch(() => { throw new WakeExecutionClaimLostError(); }); + if (current.owner_id !== lock.owner_id || current.generation !== lock.generation || current.pid !== lock.pid || current.process_start !== lock.process_start || current.boot_id !== lock.boot_id || !sameNamespace(current, lock)) throw new WakeExecutionClaimLostError(); + } + private async releaseTransitionLock(record: Stored, lock: TransitionLock): Promise { + const target = this.lockFor(record); + const current = await this.readLockOptional(target); + if (current !== undefined && current.owner_id === lock.owner_id && current.generation === lock.generation && current.pid === lock.pid && current.process_start === lock.process_start && current.boot_id === lock.boot_id && sameNamespace(current, lock)) { await unlink(target); await this.directory.sync(); } + } + private fileFor(agentId: string, deliveryId: string): string { + return path.join(this.root, `${createHash("sha256").update(`${agentId}\u0000${deliveryId}`).digest("hex")}.json`); + } + private async files(): Promise { + await this.verify(); + const entries = await readdir(this.root); + const files = entries.filter((entry) => /^[a-f0-9]{64}\.json$/.test(entry)); + if (files.length > 2_112 || entries.length > 2_176) throw new Error("wake acceptance store exceeds its bounded record limit"); + return files; + } + private async claimFiles(): Promise { return (await readdir(this.root)).filter((entry) => /^[a-f0-9]{64}\.claim$/.test(entry)); } + private async readOptional(file: string): Promise { try { return await this.read(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } + private async read(file: string): Promise { + const entry = await lstat(file); + if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 0o777) !== 0o600 || entry.size > MAX_WAKE_ACCEPTANCE_RECORD_BYTES) throw new Error("wake acceptance record is unsafe"); + const bytes = await readFile(file); + if (bytes.length > MAX_WAKE_ACCEPTANCE_RECORD_BYTES) throw new Error("wake acceptance record exceeds its bound"); + return parseStored(JSON.parse(bytes.toString("utf8"))); + } + private async readClaimOptional(file: string): Promise { try { return await this.readClaim(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } + private async readClaim(file: string): Promise { + const entry = await lstat(file); + if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 0o777) !== 0o600 || entry.size > 1_024) throw new Error("wake execution claim is unsafe"); + const value: unknown = JSON.parse((await readFile(file)).toString("utf8")); + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("wake execution claim is invalid"); + const record = value as Record; + if (Object.keys(record).length !== 3 || !Object.hasOwn(record, "owner_id") || !Object.hasOwn(record, "generation") || !Object.hasOwn(record, "expires_at")) throw new Error("wake execution claim is invalid"); + const claim = { owner_id: string(record.owner_id), generation: string(record.generation), expires_at: timestamp(record.expires_at) }; + if (!uuid(claim.owner_id) || !uuid(claim.generation)) throw new Error("wake execution claim is invalid"); + return claim; + } + private async readLockOptional(file: string): Promise { try { return await this.readLock(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } + private async readLock(file: string): Promise { + const entry = await lstat(file); + if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 0o777) !== 0o600 || entry.size > 1_024) throw new Error("wake transition lock is unsafe"); + const value: unknown = JSON.parse((await readFile(file)).toString("utf8")); + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("wake transition lock is invalid"); + const lock = value as Record; + if (Object.keys(lock).length !== 7 || !Object.hasOwn(lock, "owner_id") || !Object.hasOwn(lock, "generation") || !Object.hasOwn(lock, "pid") || !Object.hasOwn(lock, "process_start") || !Object.hasOwn(lock, "boot_id") || !Object.hasOwn(lock, "pid_namespace_dev") || !Object.hasOwn(lock, "pid_namespace_ino")) throw new Error("wake transition lock is invalid"); + const result: TransitionLock = { owner_id: string(lock.owner_id), generation: string(lock.generation), pid: integer(lock.pid), process_start: string(lock.process_start), boot_id: string(lock.boot_id), pid_namespace_dev: integer(lock.pid_namespace_dev), pid_namespace_ino: integer(lock.pid_namespace_ino) }; + if (!uuid(result.owner_id) || !uuid(result.generation) || result.pid < 1 || !result.process_start || !result.boot_id || result.pid_namespace_dev < 1 || result.pid_namespace_ino < 1) throw new Error("wake transition lock is invalid"); + return result; + } + private async transitionNowClaimed(acceptanceId: string, claim: WakeExecutionClaim): Promise { + const record = await this.findByAcceptanceId(acceptanceId); + if (record === undefined) throw new Error("wake acceptance receipt is unavailable"); + const current = await this.readClaim(this.claimFor(record)); + if (current.owner_id !== claim.owner_id || current.generation !== claim.generation || current.expires_at !== claim.expires_at || expired(current)) throw new WakeExecutionClaimLostError(); + return record; + } + private async writeNew(file: string, record: unknown): Promise { + const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow(), 0o600); + try { await handle.writeFile(JSON.stringify(record)); await handle.sync(); } finally { await handle.close(); } + } + private async replace(target: string, record: Stored): Promise { + const temporary = path.join(this.root, `.replace-${randomUUID()}`); + try { await this.writeNew(temporary, record); await rename(temporary, target); await this.directory.sync(); } finally { await unlink(temporary).catch(() => undefined); } + } + private async verify(): Promise { + await assertNoLinks(this.root); + const entry = await lstat(this.root); + const opened = await this.directory.stat(); + if (!same(identity(entry), this.identity) || !same(identity(opened), this.identity) || (await realpath(this.root)) !== this.root) throw new Error("wake acceptance store changed after validation"); + } +} + +export class WakeAcceptanceConflictError extends Error { constructor() { super("delivery id is already bound to a different request"); } } +export class WakeExecutionClaimLostError extends Error { constructor() { super("wake execution claim was lost"); } } +/** A container boundary requires deployment-authorized offline reconciliation. */ +export class WakeTransitionLockBlockedError extends Error { readonly code = "offline_reconciliation_required" as const; constructor() { super("wake transition lock requires offline reconciliation"); } } +export function publicAcceptance(record: Stored): OrganizationRuntimeWakeAcceptance { return { version: WAKE_ACCEPTANCE_VERSION, acceptance_id: record.acceptance_id, agent_id: record.agent_id, delivery_id: record.delivery_id, request_digest: record.request_digest, state: "accepted", accepted_at: record.accepted_at }; } +function publicStatus(record: Stored): OrganizationRuntimeWakeReceiptStatus { return { version: WAKE_RECEIPT_STATUS_VERSION, acceptance_id: record.acceptance_id, agent_id: record.agent_id, delivery_id: record.delivery_id, request_digest: record.request_digest, state: record.state, accepted_at: record.accepted_at, updated_at: record.updated_at, ...(record.code === undefined ? {} : { code: record.code }) }; } +function parseStored(value: unknown): Stored { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("wake acceptance record is invalid"); + const record = value as Record; + const keys = ["acceptance_id", "agent_id", "delivery_id", "request_digest", "event", "state", "accepted_at", "updated_at", "claim_generation", "code"]; + if (Object.keys(record).some((key) => !keys.includes(key))) throw new Error("wake acceptance record is invalid"); + const event = record.event; + const request = { token: undefined, agent_id: string(record.agent_id), delivery_id: string(record.delivery_id), event }; + const parsed = parseWakeAcceptanceRequest(request); + const state = string(record.state) as WakeReceiptState; + if (!(["accepted", "running", "completed", "failed", "stopped"] as const).includes(state)) throw new Error("wake acceptance record is invalid"); + const code = record.code === undefined ? undefined : string(record.code) as WakeReceiptCode; + const claimGeneration = record.claim_generation === undefined ? undefined : string(record.claim_generation); + if (claimGeneration !== undefined && !uuid(claimGeneration)) throw new Error("wake acceptance record is invalid"); + if (code !== undefined && !(["engine_failed", "host_stopped", "host_stopping", "queue_full", "unknown_agent"] as const).includes(code)) throw new Error("wake acceptance record is invalid"); + if ((state === "accepted" || state === "running" || state === "completed") && code !== undefined) throw new Error("wake acceptance record is invalid"); + if ((state === "failed" || state === "stopped") && code === undefined) throw new Error("wake acceptance record is invalid"); + if (string(record.request_digest) !== wakeAcceptanceDigest(parsed) || !uuid(string(record.acceptance_id))) throw new Error("wake acceptance record is invalid"); + return { acceptance_id: string(record.acceptance_id), agent_id: parsed.agent_id, delivery_id: parsed.delivery_id, request_digest: string(record.request_digest), event: parsed.event, state, accepted_at: timestamp(record.accepted_at), updated_at: timestamp(record.updated_at), ...(claimGeneration === undefined ? {} : { claim_generation: claimGeneration }), ...(code === undefined ? {} : { code }) }; +} +function string(value: unknown): string { if (typeof value !== "string") throw new Error("wake acceptance record is invalid"); return value; } +function integer(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new Error("wake transition lock is invalid"); return value; } +function timestamp(value: unknown): string { const result = string(value); if (Number.isNaN(Date.parse(result)) || new Date(result).toISOString() !== result) throw new Error("wake acceptance record is invalid"); return result; } +function uuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value); } +function isTerminal(state: WakeReceiptState): boolean { return state === "completed" || state === "failed" || state === "stopped"; } +function expired(claim: WakeExecutionClaim): boolean { return Date.parse(claim.expires_at) <= Date.now(); } +function identity(value: Awaited>): DirectoryIdentity { + const numeric = value as typeof value & { dev: number; ino: number; uid: number; mode: number }; + return { dev: numeric.dev, ino: numeric.ino, uid: numeric.uid, mode: numeric.mode & 0o7777 }; +} +function same(left: DirectoryIdentity, right: DirectoryIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode; } +function sameNamespace(left: Pick, right: Pick): boolean { return left.pid_namespace_dev === right.pid_namespace_dev && left.pid_namespace_ino === right.pid_namespace_ino; } +function noFollow(): number { return (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; } +function directoryFlag(): number { return (constants as typeof constants & { O_DIRECTORY?: number }).O_DIRECTORY ?? 0; } +async function assertNoLinks(target: string): Promise { let current = path.parse(target).root; for (const part of path.relative(current, target).split(path.sep).filter(Boolean)) { current = path.join(current, part); if ((await lstat(current)).isSymbolicLink() && current !== "/var") throw new Error("wake acceptance store path contains a symlink"); } } +async function currentProcessIdentity(): Promise> { + if (process.platform !== "linux") throw new Error("wake transition locks require Linux process identity"); + const boot_id = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim(); + if (!boot_id) throw new Error("wake transition owner identity is invalid"); + return { pid: process.pid, process_start: await linuxProcessStart(process.pid), boot_id, ...await linuxPidNamespace() }; +} +async function processIsAlive(lock: TransitionLock): Promise { + if (process.platform !== "linux") throw new Error("wake transition lock liveness is unsupported"); + if (!sameNamespace(lock, await linuxPidNamespace())) throw new WakeTransitionLockBlockedError(); + const boot = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim(); + if (!boot) throw new Error("wake transition owner liveness cannot be proven"); + if (boot !== lock.boot_id) return false; + try { return await linuxProcessStart(lock.pid) === lock.process_start; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw new Error("wake transition owner liveness cannot be proven"); } +} +async function linuxProcessStart(pid: number): Promise { + const stat = await readFile(`/proc/${pid}/stat`, "utf8"); + const close = stat.lastIndexOf(")"); + const fields = stat.slice(close + 2).trim().split(/\s+/u); + const start = fields[19]; + if (close < 0 || start === undefined || !/^\d+$/u.test(start)) throw new Error("wake transition owner identity is invalid"); + return start; +} +async function linuxPidNamespace(): Promise> { + const identity = await stat("/proc/self/ns/pid"); + const pid_namespace_dev = Number(identity.dev); + const pid_namespace_ino = Number(identity.ino); + if (!Number.isSafeInteger(pid_namespace_dev) || !Number.isSafeInteger(pid_namespace_ino) || pid_namespace_dev < 1 || pid_namespace_ino < 1) throw new Error("wake transition owner identity is invalid"); + return { pid_namespace_dev, pid_namespace_ino }; +} diff --git a/src/runtime/wakeAcceptanceTypes.ts b/src/runtime/wakeAcceptanceTypes.ts new file mode 100644 index 0000000..38cdc26 --- /dev/null +++ b/src/runtime/wakeAcceptanceTypes.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; + +export const WAKE_ACCEPTANCE_VERSION = "noopolis.daimon.wake-acceptance.v2" as const; +export const WAKE_RECEIPT_STATUS_VERSION = "noopolis.daimon.wake-receipt-status.v2" as const; +export const WAKE_V2_VERSION = "noopolis.daimon.wake.v2" as const; +export const MAX_WAKE_ACCEPTANCE_BYTES = 16_384; +export const MAX_WAKE_ACCEPTANCE_RECORD_BYTES = 65_536; + +/** Public HTTP body schema; bearer authentication is intentionally a header. */ +export const WAKE_ACCEPTANCE_REQUEST_SCHEMA = { + $schema: "https://json-schema.org/draft/2020-12/schema", $id: "noopolis.daimon.wake-request.v2", type: "object", additionalProperties: false, + required: ["agent_id", "delivery_id", "event"], properties: { + agent_id: { type: "string", minLength: 1, maxLength: MAX_WAKE_ACCEPTANCE_BYTES, pattern: "\\S" }, + delivery_id: { type: "string", minLength: 1, maxLength: MAX_WAKE_ACCEPTANCE_BYTES, pattern: "\\S" }, + event: { type: "object", additionalProperties: false, required: ["version", "kind", "text", "occurred_at"], properties: { + version: { const: WAKE_V2_VERSION }, kind: { enum: ["manual", "message", "external"] }, text: { type: "string", maxLength: MAX_WAKE_ACCEPTANCE_BYTES }, occurred_at: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" } + } } + } +} as const; +export const WAKE_RECEIPT_STATUS_SCHEMA = { + $schema: "https://json-schema.org/draft/2020-12/schema", $id: WAKE_RECEIPT_STATUS_VERSION, type: "object", additionalProperties: false, + required: ["version", "acceptance_id", "agent_id", "delivery_id", "request_digest", "state", "accepted_at", "updated_at"], properties: { + version: { const: WAKE_RECEIPT_STATUS_VERSION }, acceptance_id: { type: "string", pattern: "^[0-9a-f-]{36}$" }, agent_id: { type: "string" }, delivery_id: { type: "string" }, request_digest: { type: "string", pattern: "^[a-f0-9]{64}$" }, state: { enum: ["accepted", "running", "completed", "failed", "stopped"] }, accepted_at: { type: "string" }, updated_at: { type: "string" }, code: { enum: ["engine_failed", "host_stopped", "host_stopping", "queue_full", "unknown_agent"] } + } +} as const; + +export type WakeReceiptState = "accepted" | "running" | "completed" | "failed" | "stopped"; +export type WakeReceiptCode = "engine_failed" | "host_stopped" | "host_stopping" | "queue_full" | "unknown_agent"; +export type OrganizationRuntimeWakeAcceptanceRequest = Readonly<{ + token: string | undefined; + agent_id: string; + delivery_id: string; + event: Readonly<{ version: typeof WAKE_V2_VERSION; kind: "manual" | "message" | "external"; text: string; occurred_at: string }>; +}>; +export type OrganizationRuntimeWakeAcceptance = Readonly<{ + version: typeof WAKE_ACCEPTANCE_VERSION; + acceptance_id: string; + agent_id: string; + delivery_id: string; + request_digest: string; + state: "accepted"; + accepted_at: string; +}>; +export type OrganizationRuntimeWakeReceiptStatus = Readonly<{ + version: typeof WAKE_RECEIPT_STATUS_VERSION; + acceptance_id: string; + agent_id: string; + delivery_id: string; + request_digest: string; + state: WakeReceiptState; + accepted_at: string; + updated_at: string; + code?: WakeReceiptCode; +}>; +export type OrganizationRuntimeWakeAcceptanceResult = OrganizationRuntimeWakeAcceptance | Readonly<{ + version: typeof WAKE_ACCEPTANCE_VERSION; + state: "rejected" | "stopped"; + code: "unauthorized" | "invalid_request" | "unknown_agent" | "host_stopping" | "host_stopped" | "delivery_conflict"; +}>; + +export function parseWakeAcceptanceRequest(value: unknown): OrganizationRuntimeWakeAcceptanceRequest { + const root = record(snapshot(value, "wake acceptance"), "wake acceptance"); + exact(root, ["token", "agent_id", "delivery_id", "event"], "wake acceptance"); + const event = record(root.event, "wake acceptance.event"); + exact(event, ["version", "kind", "text", "occurred_at"], "wake acceptance.event"); + const kind = text(event.kind, "wake acceptance.event.kind"); + if (kind !== "manual" && kind !== "message" && kind !== "external") throw new TypeError("wake acceptance.event.kind is not supported"); + const body = text(event.text, "wake acceptance.event.text"); + if (Buffer.byteLength(body, "utf8") > MAX_WAKE_ACCEPTANCE_BYTES) throw new TypeError("wake acceptance.event.text exceeds the wake text limit"); + if (text(event.version, "wake acceptance.event.version") !== WAKE_V2_VERSION) throw new TypeError("wake acceptance.event.version is not supported"); + return { + token: root.token === undefined ? undefined : text(root.token, "wake acceptance.token"), + agent_id: nonBlank(root.agent_id, "wake acceptance.agent_id"), + delivery_id: nonBlank(root.delivery_id, "wake acceptance.delivery_id"), + event: { version: WAKE_V2_VERSION, kind, text: body, occurred_at: rfc3339(event.occurred_at) } + }; +} + +export function wakeAcceptanceDigest(request: OrganizationRuntimeWakeAcceptanceRequest): string { + const canonical = JSON.stringify({ agent_id: request.agent_id, delivery_id: request.delivery_id, event: request.event }); + return createHash("sha256").update(canonical).digest("hex"); +} + +function snapshot(value: unknown, label: string): unknown { + if (value === undefined || value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return value; + if (Array.isArray(value)) throw new TypeError(`${label} must be an object`); + if (typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError(`${label} must be a plain own-properties object`); + const copied: Record = Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string") throw new TypeError(`${label} must not contain symbol properties`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) throw new TypeError(`${label}.${key} must be an enumerable data property`); + copied[key] = snapshot(descriptor.value, `${label}.${key}`); + } + return copied; +} +function record(value: unknown, label: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError(`${label} must be a plain own-properties object`); + return value as Record; +} +function exact(value: Record, expected: readonly string[], label: string): void { + if (Object.keys(value).some((key) => !expected.includes(key)) || expected.some((key) => !Object.hasOwn(value, key))) throw new TypeError(`${label} must contain exactly ${expected.join(", ")}`); +} +function text(value: unknown, label: string): string { + if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > MAX_WAKE_ACCEPTANCE_BYTES) throw new TypeError(`${label} must be a bounded string`); + return value; +} +function nonBlank(value: unknown, label: string): string { const result = text(value, label); if (!result.trim()) throw new TypeError(`${label} must not be blank`); return result; } +function rfc3339(value: unknown): string { + const result = nonBlank(value, "wake acceptance.event.occurred_at"); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(result) || Number.isNaN(Date.parse(result)) || new Date(result).toISOString() !== result) throw new TypeError("wake acceptance.event.occurred_at must be an exact RFC3339 timestamp"); + return result; +} From 8fc21aa43bf613c0fd5f580a9df42f63edab1d74 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:11 +0200 Subject: [PATCH 02/24] feat(contracts): publish canonical runtime manifests --- scripts/emitRuntimeContractManifest.mjs | 60 +++++++++++++ src/contracts/AGENTS.md | 9 ++ src/contracts/CLAUDE.md | 1 + src/contracts/canonicalJson.ts | 47 ++++++++++ src/contracts/organizationRuntimeContract.ts | 84 ++++++++++++++++++ src/contracts/runtimeContractManifest.ts | 88 +++++++++++++++++++ src/runtime/contractManifest.test.ts | 26 +++++- src/runtime/contractManifest.ts | 73 +++++---------- src/runtime/contractManifest.types.test.ts | 25 ++++++ src/runtime/contractManifestArtifacts.test.ts | 61 +++++++++++++ 10 files changed, 418 insertions(+), 56 deletions(-) create mode 100644 scripts/emitRuntimeContractManifest.mjs create mode 100644 src/contracts/AGENTS.md create mode 120000 src/contracts/CLAUDE.md create mode 100644 src/contracts/canonicalJson.ts create mode 100644 src/contracts/organizationRuntimeContract.ts create mode 100644 src/contracts/runtimeContractManifest.ts create mode 100644 src/runtime/contractManifest.types.test.ts create mode 100644 src/runtime/contractManifestArtifacts.test.ts diff --git a/scripts/emitRuntimeContractManifest.mjs b/scripts/emitRuntimeContractManifest.mjs new file mode 100644 index 0000000..7e931d3 --- /dev/null +++ b/scripts/emitRuntimeContractManifest.mjs @@ -0,0 +1,60 @@ +import { createHash } from "node:crypto"; +import { readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { canonicalJson } from "../src/contracts/canonicalJson.ts"; +import { RUNTIME_CONTRACT_MANIFEST } from "../src/contracts/runtimeContractManifest.ts"; + +const runtimeDist = fileURLToPath(new URL("../dist/runtime/", import.meta.url)); +const manifestName = "contract-manifest.json"; +const digestName = "contract-manifest.sha256"; + +export const contractManifestArtifacts = () => { + const canonical = canonicalJson(RUNTIME_CONTRACT_MANIFEST); + const parsed = JSON.parse(canonical); + if (canonicalJson(parsed) !== canonical) throw new Error("contract manifest failed canonical round trip"); + const manifest = Buffer.from(`${canonical}\n`, "utf8"); + const hash = createHash("sha256").update(manifest).digest("hex"); + if (!/^[a-f0-9]{64}$/.test(hash)) throw new Error("contract manifest digest is not lowercase sha256"); + return Object.freeze({ manifest, digest: Buffer.from(`sha256:${hash}\n`, "ascii") }); +}; + +const assertRuntimeDist = async (outputDirectory) => { + const entry = await stat(outputDirectory); + if (!entry.isDirectory()) throw new Error("compiled runtime output is not a directory"); +}; + +export const emitContractManifestArtifacts = async (outputDirectory = runtimeDist) => { + await assertRuntimeDist(outputDirectory); + const artifacts = contractManifestArtifacts(); + await writeFile(path.join(outputDirectory, manifestName), artifacts.manifest, { flag: "w" }); + await writeFile(path.join(outputDirectory, digestName), artifacts.digest, { flag: "w" }); +}; + +export const verifyContractManifestArtifacts = async (outputDirectory = runtimeDist) => { + await assertRuntimeDist(outputDirectory); + const expected = contractManifestArtifacts(); + const [manifest, digest] = await Promise.all([ + readFile(path.join(outputDirectory, manifestName)), + readFile(path.join(outputDirectory, digestName)) + ]); + if (!manifest.equals(expected.manifest) || !digest.equals(expected.digest)) { + throw new Error("emitted runtime contract artifacts drift from source constants"); + } +}; + +const main = async () => { + const args = process.argv.slice(2); + if (args.length === 0) return emitContractManifestArtifacts(); + if (args.length === 1 && args[0] === "--check") return verifyContractManifestArtifacts(); + throw new Error("usage: emitRuntimeContractManifest.mjs [--check]"); +}; + +const invoked = process.argv[1]; +if (invoked !== undefined && import.meta.url === pathToFileURL(path.resolve(invoked)).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : "contract artifact generation failed"}\n`); + process.exitCode = 1; + }); +} diff --git a/src/contracts/AGENTS.md b/src/contracts/AGENTS.md new file mode 100644 index 0000000..5194d21 --- /dev/null +++ b/src/contracts/AGENTS.md @@ -0,0 +1,9 @@ +# Data-only contract guide + +`src/contracts` owns runtime-neutral constants, schemas, manifest data, and +pure canonicalization used by runtime code and emitted artifacts. + +Keep this folder deterministic and free of I/O, environment access, secrets, +process control, runtime orchestration, and Pi-specific types. Contract objects +must remain immutable literal data suitable for canonical JSON emission. +Runtime behavior belongs in `src/runtime/`; Pi integration belongs in `src/pi/`. diff --git a/src/contracts/CLAUDE.md b/src/contracts/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/contracts/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/contracts/canonicalJson.ts b/src/contracts/canonicalJson.ts new file mode 100644 index 0000000..ce2c4af --- /dev/null +++ b/src/contracts/canonicalJson.ts @@ -0,0 +1,47 @@ +const invalid = (detail: string): never => { + throw new TypeError(`value is not canonical JSON data: ${detail}`); +}; + +const assertUnicodeScalarString = (value: string): void => { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) invalid("unpaired surrogate"); + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + invalid("unpaired surrogate"); + } + } +}; + +/** Deterministically serializes JSON data and rejects non-JSON or ambiguous values. */ +export const canonicalJson = (value: unknown): string => { + if (value === null || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value) || Object.is(value, -0)) invalid("non-finite or negative-zero number"); + return JSON.stringify(value); + } + if (typeof value === "string") { + assertUnicodeScalarString(value); + return JSON.stringify(value); + } + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) invalid("sparse array"); + } + return `[${value.map(canonicalJson).join(",")}]`; + } + if (typeof value !== "object") return invalid(typeof value); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) invalid("non-plain object"); + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== "string")) invalid("symbol key"); + const keys = (ownKeys as string[]).sort(); + return `{${keys.map((key) => { + assertUnicodeScalarString(key); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) return invalid("non-data property"); + return `${JSON.stringify(key)}:${canonicalJson(descriptor.value)}`; + }).join(",")}}`; +}; diff --git a/src/contracts/organizationRuntimeContract.ts b/src/contracts/organizationRuntimeContract.ts new file mode 100644 index 0000000..1aaa180 --- /dev/null +++ b/src/contracts/organizationRuntimeContract.ts @@ -0,0 +1,84 @@ +/** The data-only organization-runtime constants shared by product code and artifacts. */ +export const ORGANIZATION_RUNTIME_VERSION = "noopolis.daimon.organization-runtime.v1" as const; +export const ORGANIZATION_RUNTIME_V2_VERSION = "noopolis.daimon.organization-runtime.v2" as const; +export const ORGANIZATION_RUNTIME_MAX_AGENTS = 32; +export const ORGANIZATION_RUNTIME_MAX_CONFIG_BYTES = 1_048_576; +export const ORGANIZATION_RUNTIME_MAX_STRING_BYTES = 16_384; +export const ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS = 4_096; +export const ORGANIZATION_RUNTIME_MAX_WAKE_TEXT_BYTES = 16_384; +export const ORGANIZATION_RUNTIME_MAX_SCHEDULE_INTERVAL_MS = 31_536_000_000; + +const PRODUCTION_TOOL_PROPERTIES = { + mcp: { type: "array", maxItems: 8, items: { type: "object", additionalProperties: false, required: ["name", "transport", "args", "env", "tools"], properties: { + name: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS }, transport: { enum: ["stdio", "sse", "streamable_http"] }, + command: { type: "string", pattern: "^/" }, url: { type: "string" }, authSecretEnv: { type: "string", pattern: "^[A-Za-z_][A-Za-z0-9_]*$" }, + args: { type: "array", maxItems: 32, items: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS } }, env: { type: "object", additionalProperties: { type: "string" } }, + tools: { type: "array", minItems: 1, maxItems: 32, uniqueItems: true, items: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS } } + } } }, + moltnet: { type: "object", additionalProperties: false, required: ["cliPath", "configPath", "networks"], properties: { + cliPath: { type: "string", pattern: "^/" }, configPath: { type: "string", pattern: "^/" }, networks: { type: "array", maxItems: 16, items: { type: "object", additionalProperties: false, required: ["id", "rooms", "dms"], properties: { id: { type: "string", minLength: 1 }, rooms: { type: "array", uniqueItems: true, items: { type: "string", minLength: 1 } }, dms: { type: "boolean" } } } } + } } +} as const; + +/** Machine-readable companion schema for config producers and validators. */ +export const ORGANIZATION_RUNTIME_CONFIG_SCHEMA = { + $schema: "https://json-schema.org/draft/2020-12/schema", $id: ORGANIZATION_RUNTIME_VERSION, type: "object", additionalProperties: false, + required: ["version", "host", "agents"], + properties: { + version: { const: ORGANIZATION_RUNTIME_VERSION }, + host: { type: "object", additionalProperties: false, required: ["bindHost", "port", "controlTokenEnv"], properties: { + bindHost: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + port: { type: "integer", minimum: 1, maximum: 65_535 }, + controlTokenEnv: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "^[A-Za-z_][A-Za-z0-9_]*$" } + } }, + agents: { type: "array", minItems: 1, maxItems: ORGANIZATION_RUNTIME_MAX_AGENTS, items: { + type: "object", additionalProperties: false, required: ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine"], properties: { + id: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + name: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + instructions: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + workspacePath: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "^/" }, + runtimeHomePath: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "^/" }, + engine: { type: "object", additionalProperties: false, required: ["kind"], properties: { + kind: { enum: ["codex", "grok", "agy"] } + } }, + ...PRODUCTION_TOOL_PROPERTIES + } + } } + } +} as const; + +export const ORGANIZATION_RUNTIME_SCHEDULE_SCHEMA = { + oneOf: [ + { type: "object", additionalProperties: false, required: ["kind"], properties: { kind: { const: "disabled" } } }, + { type: "object", additionalProperties: false, required: ["kind", "interval_ms", "prompt"], properties: { + kind: { const: "every" }, interval_ms: { type: "integer", minimum: 1, maximum: ORGANIZATION_RUNTIME_MAX_SCHEDULE_INTERVAL_MS }, + prompt: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" } + } }, + { type: "object", additionalProperties: false, required: ["kind", "cron", "timezone", "prompt"], properties: { + kind: { const: "cron" }, cron: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + timezone: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + prompt: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" } + } } + ] +} as const; + +/** v2 adds exactly one normalized schedule to every agent; v1 stays unchanged. */ +export const ORGANIZATION_RUNTIME_CONFIG_V2_SCHEMA = { + ...ORGANIZATION_RUNTIME_CONFIG_SCHEMA, + $id: ORGANIZATION_RUNTIME_V2_VERSION, + properties: { + ...ORGANIZATION_RUNTIME_CONFIG_SCHEMA.properties, + version: { const: ORGANIZATION_RUNTIME_V2_VERSION }, + agents: { + ...ORGANIZATION_RUNTIME_CONFIG_SCHEMA.properties.agents, + items: { + ...ORGANIZATION_RUNTIME_CONFIG_SCHEMA.properties.agents.items, + required: [...ORGANIZATION_RUNTIME_CONFIG_SCHEMA.properties.agents.items.required, "schedule"], + properties: { + ...ORGANIZATION_RUNTIME_CONFIG_SCHEMA.properties.agents.items.properties, + schedule: ORGANIZATION_RUNTIME_SCHEDULE_SCHEMA + } + } + } + } +} as const; diff --git a/src/contracts/runtimeContractManifest.ts b/src/contracts/runtimeContractManifest.ts new file mode 100644 index 0000000..2e5cf95 --- /dev/null +++ b/src/contracts/runtimeContractManifest.ts @@ -0,0 +1,88 @@ +import { + ORGANIZATION_RUNTIME_CONFIG_SCHEMA, + ORGANIZATION_RUNTIME_CONFIG_V2_SCHEMA, + ORGANIZATION_RUNTIME_MAX_AGENTS, + ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS +} from "./organizationRuntimeContract.js"; + +export const RUNTIME_CONTRACT_MANIFEST_VERSION = "noopolis.daimon.runtime-contract-manifest.v3" as const; +export const ORGANIZATION_RUNTIME_ACTIVITY_V2_VERSION = "noopolis.daimon.organization-runtime-activity.v2" as const; +export const ENGINE_CREDENTIAL_MATERIAL = { + codex: { sourceSlot: "codex-auth", sourceRelativePath: ".daimon-inbound/codex-auth", destinationRelativePath: ".codex/auth.json", directoryMode: 0o700, fileMode: 0o600 } +} as const; +export const GROK_SUBSCRIPTION_REALM = { + agentCredentialRelativePath: ".grok/auth.json", + bootstrapMountPath: "/var/lib/spawnfile/daimon/grok-bootstrap-auth", + bootstrapSourceSlot: "grok-auth", + directoryMode: 0o700, + durableMountPath: "/var/lib/spawnfile/daimon/grok-subscription-realm", + fileMode: 0o600, + maxCredentialBytes: 64 * 1024 +} as const; +export const GROK_ENGINE_BROKER = { + nativeAbiVersion: 2, + nativeExecutablePath: "/opt/daimon/bin/daimon-engine-broker", + grokExecutablePath: "/usr/local/bin/grok", + registrationPath: "/etc/daimon-engine-broker/registrations.bin", + credentialHomePath: "/var/lib/spawnfile/daimon/grok-subscription-realm", + turnStorePath: "/var/lib/spawnfile/daimon/grok-subscription-realm/turns", + controlSocketPath: "/run/daimon-engine-broker/control.sock", + backendSocketPath: "/run/daimon-engine-broker/backend.sock", + launcherSocketPath: "/run/daimon-engine-broker/launcher.sock", + serviceConfigPath: "/etc/daimon-engine-broker/service.json", + providerProxy: { host: "127.0.0.1", port: 43_123 }, + mcpFacade: { host: "127.0.0.1", port: 43_124, path: "/mcp" }, + identities: { organizationUid: 2_000, brokerUid: 2_100, firstWorkerUid: 2_200 }, + bounds: { promptBytes: 65_536, capabilityBytes: 4_096, capabilityBundleBytes: 8_196, outputBytes: 65_536 }, + artifacts: { + sourceSha256: "bdcab1e12dcc531ed8e56f890263ca23a9ee7bac468191dd598e143df4ff8c58", + x64Sha256: "e3fe2738fc8a979861085b4003bf2d5d7c284874897cb6ec2e2e2383211768bd", + arm64Sha256: "ad44e02c38e6a3207ac4a3d5fd98b6d2e55341ce42dfd2f07204bbe54a7a653d" + } +} as const; +export const AGY_SUBSCRIPTION_REALM = { + durableMountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", + unlockMountPath: "/var/lib/spawnfile/daimon/agy-unlock-secret", + unlockSourceSlot: "agy-unlock-secret", + directoryMode: 0o700, + fileMode: 0o600, + maxUnlockBytes: 4_096 +} as const; + +const text = { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" } as const; +const boundedText = { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS } as const; +const timestamp = { type: "string", format: "date-time", pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" } as const; +const wakeEvent = { type: "object", additionalProperties: false, required: ["version", "id", "kind", "text", "occurredAt"], properties: { version: { const: "noopolis.daimon.wake.v1" }, id: text, kind: { enum: ["manual", "message", "schedule", "external"] }, text: boundedText, occurredAt: timestamp } } as const; +const wakeResultBase = { version: { const: "noopolis.daimon.wake-result.v1" }, agentId: text, wakeId: text } as const; +const activityItem = { type: "object", additionalProperties: false, required: ["id", "agentId", "kind", "occurredAt"], properties: { id: { type: "string", format: "uuid", pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" }, agentId: text, wakeId: text, kind: { enum: ["wake_started", "wake_completed", "wake_rejected", "wake_aborted", "agent_stopped"] }, occurredAt: timestamp } } as const; + +export const RUNTIME_CONTRACT_MANIFEST = { + version: RUNTIME_CONTRACT_MANIFEST_VERSION, + consumedConfigFields: ["version", "host.bindHost", "host.port", "host.controlTokenEnv", "agents[].id", "agents[].name", "agents[].instructions", "agents[].workspacePath", "agents[].runtimeHomePath", "agents[].engine.kind", "agents[].schedule.kind", "agents[].schedule.interval_ms", "agents[].schedule.cron", "agents[].schedule.timezone", "agents[].schedule.prompt", "agents[].mcp", "agents[].moltnet"], + organizationRuntimeConfigSchema: ORGANIZATION_RUNTIME_CONFIG_SCHEMA, + organizationRuntimeConfigV2Schema: ORGANIZATION_RUNTIME_CONFIG_V2_SCHEMA, + wakeAcceptanceTypes: ["manual", "message", "schedule", "external"], + deliverySemantics: { + activeDeliveryIdempotency: "unbounded-until-terminal", + terminalReceiptHorizon: 2_048, + recovery: "at-least-once-with-stable-wake-id", + concurrentSameAgentTurns: false, + externalEffectsExactlyOnce: false + }, + supportedEngineKinds: ["agy", "codex", "grok"], + engineCredentialMaterial: ENGINE_CREDENTIAL_MATERIAL, + grokSubscriptionRealm: GROK_SUBSCRIPTION_REALM, + grokEngineBroker: GROK_ENGINE_BROKER, + agySubscriptionRealm: AGY_SUBSCRIPTION_REALM, + wakeRequestSchema: { type: "object", additionalProperties: false, required: ["agentId", "event"], properties: { agentId: text, event: wakeEvent } }, + wakeResultSchema: { oneOf: [ + { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "text", "durationMs"], properties: { ...wakeResultBase, status: { const: "completed" }, text: boundedText, durationMs: { type: "integer", minimum: 0 } } }, + { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { ...wakeResultBase, status: { const: "rejected" }, code: { enum: ["unauthorized", "unknown_agent", "queue_full"] } } }, + { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { version: { const: "noopolis.daimon.wake-result.v1" }, status: { const: "rejected" }, agentId: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS }, wakeId: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS }, code: { const: "invalid_request" } } }, + { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { ...wakeResultBase, status: { const: "stopped" }, code: { enum: ["host_stopping", "host_stopped", "queued_wake_stopped", "active_wake_aborted"] } } }, + { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { ...wakeResultBase, status: { const: "failed" }, code: { const: "engine_failed" } } } + ] }, + healthResponseSchema: { type: "object", additionalProperties: false, required: ["version", "state", "agents"], properties: { version: { const: "noopolis.daimon.organization-runtime-health.v1" }, state: { enum: ["starting", "running", "stopping", "stopped"] }, agents: { type: "array", maxItems: ORGANIZATION_RUNTIME_MAX_AGENTS, items: { type: "object", additionalProperties: false, required: ["agentId", "state"], properties: { agentId: text, state: { enum: ["starting", "running", "stopping", "stopped", "idle", "failed"] } } } } } }, + activityResponseSchema: { type: "object", additionalProperties: false, required: ["version", "items"], properties: { version: { const: "noopolis.daimon.organization-runtime-activity.v1" }, items: { type: "array", maxItems: 100, items: activityItem }, nextCursor: { type: "string", minLength: 1, maxLength: 16, pattern: "^(0|[1-9][0-9]{0,15})$" } } }, + activityV2ResponseSchema: { type: "object", additionalProperties: false, required: ["version", "items"], properties: { version: { const: ORGANIZATION_RUNTIME_ACTIVITY_V2_VERSION }, items: { type: "array", maxItems: 2_112, items: { type: "object", additionalProperties: false, required: ["version", "acceptance_id", "agent_id", "delivery_id", "request_digest", "state", "accepted_at", "updated_at", "active"], properties: { version: { const: "noopolis.daimon.wake-receipt-status.v2" }, acceptance_id: { type: "string" }, agent_id: text, delivery_id: text, request_digest: { type: "string" }, state: { enum: ["accepted", "running", "completed", "failed", "stopped"] }, accepted_at: timestamp, updated_at: timestamp, active: { type: "boolean" }, queue_position: { type: "integer", minimum: 1 }, code: { enum: ["engine_failed", "host_stopped", "host_stopping", "queue_full", "unknown_agent"] } } } } } } +} as const; diff --git a/src/runtime/contractManifest.test.ts b/src/runtime/contractManifest.test.ts index a663787..3d8ad65 100644 --- a/src/runtime/contractManifest.test.ts +++ b/src/runtime/contractManifest.test.ts @@ -1,19 +1,31 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { AGY_SUBSCRIPTION_REALM, canonicalJson, canonicalRuntimeContractManifest, ENGINE_CREDENTIAL_MATERIAL, RUNTIME_CONTRACT_MANIFEST } from "./contractManifest.js"; +import { AGY_SUBSCRIPTION_REALM, canonicalJson, canonicalRuntimeContractManifest, ENGINE_CREDENTIAL_MATERIAL, GROK_SUBSCRIPTION_REALM, RUNTIME_CONTRACT_MANIFEST, RUNTIME_CONTRACT_MANIFEST_VERSION } from "./contractManifest.js"; test("runtime contract manifest is data-only and deterministic", () => { const first = canonicalRuntimeContractManifest(); assert.equal(first, canonicalRuntimeContractManifest()); assert.equal(first, canonicalJson(RUNTIME_CONTRACT_MANIFEST)); - assert.deepEqual(Object.keys(ENGINE_CREDENTIAL_MATERIAL), ["codex", "grok"]); + assert.deepEqual(Object.keys(ENGINE_CREDENTIAL_MATERIAL), ["codex"]); assert.deepEqual(RUNTIME_CONTRACT_MANIFEST.supportedEngineKinds, ["agy", "codex", "grok"]); + assert.equal(RUNTIME_CONTRACT_MANIFEST_VERSION, "noopolis.daimon.runtime-contract-manifest.v3"); + assert.equal(RUNTIME_CONTRACT_MANIFEST.organizationRuntimeConfigV2Schema.$id, "noopolis.daimon.organization-runtime.v2"); + assert.deepEqual(RUNTIME_CONTRACT_MANIFEST.wakeAcceptanceTypes, ["manual", "message", "schedule", "external"]); + assert.deepEqual(RUNTIME_CONTRACT_MANIFEST.deliverySemantics, { + activeDeliveryIdempotency: "unbounded-until-terminal", terminalReceiptHorizon: 2_048, + recovery: "at-least-once-with-stable-wake-id", concurrentSameAgentTurns: false, externalEffectsExactlyOnce: false + }); + assert.equal(RUNTIME_CONTRACT_MANIFEST.activityV2ResponseSchema.properties.version.const, "noopolis.daimon.organization-runtime-activity.v2"); + assert.deepEqual(RUNTIME_CONTRACT_MANIFEST.wakeRequestSchema.properties.event.properties.kind.enum, ["manual", "message", "schedule", "external"]); + assert.equal("maximum" in RUNTIME_CONTRACT_MANIFEST.wakeResultSchema.oneOf[0].properties.durationMs, false); assert.equal(RUNTIME_CONTRACT_MANIFEST.agySubscriptionRealm, AGY_SUBSCRIPTION_REALM); + assert.equal(RUNTIME_CONTRACT_MANIFEST.grokSubscriptionRealm, GROK_SUBSCRIPTION_REALM); + assert.equal(GROK_SUBSCRIPTION_REALM.agentCredentialRelativePath, ".grok/auth.json"); assert.equal(AGY_SUBSCRIPTION_REALM.directoryMode, 0o700); assert.equal(AGY_SUBSCRIPTION_REALM.fileMode, 0o600); assert.doesNotMatch(first, /antigravity-oauth-token|agy-auth/); - assert.doesNotMatch(first, /DBUS_SESSION_BUS_ADDRESS|gnome-keyring|dbus-daemon|argv|command|schedule|moltnet|credential value/i); + assert.doesNotMatch(first, /DBUS_SESSION_BUS_ADDRESS|gnome-keyring|dbus-daemon|argv|credential value/i); for (const rule of Object.values(ENGINE_CREDENTIAL_MATERIAL)) { assert.equal(rule.directoryMode, 0o700); assert.equal(rule.fileMode, 0o600); @@ -21,3 +33,11 @@ test("runtime contract manifest is data-only and deterministic", () => { assert.ok(!rule.destinationRelativePath.startsWith("/") && !rule.destinationRelativePath.includes("..")); } }); + +test("canonical JSON rejects values with lossy or environment-dependent encodings", () => { + for (const value of [undefined, Number.NaN, Number.POSITIVE_INFINITY, -0, 1n, [, "value"], new Date(0)]) { + assert.throws(() => canonicalJson(value)); + } + assert.throws(() => canonicalJson({ value: undefined })); + assert.throws(() => canonicalJson("\ud800")); +}); diff --git a/src/runtime/contractManifest.ts b/src/runtime/contractManifest.ts index 0b129a7..428d629 100644 --- a/src/runtime/contractManifest.ts +++ b/src/runtime/contractManifest.ts @@ -1,60 +1,27 @@ +import { canonicalJson } from "../contracts/canonicalJson.js"; import { - ORGANIZATION_RUNTIME_CONFIG_SCHEMA, - ORGANIZATION_RUNTIME_MAX_AGENTS, - ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS -} from "./organizationRuntime.js"; + AGY_SUBSCRIPTION_REALM, + ENGINE_CREDENTIAL_MATERIAL, + GROK_ENGINE_BROKER, + GROK_SUBSCRIPTION_REALM, + ORGANIZATION_RUNTIME_ACTIVITY_V2_VERSION, + RUNTIME_CONTRACT_MANIFEST, + RUNTIME_CONTRACT_MANIFEST_VERSION +} from "../contracts/runtimeContractManifest.js"; -export const RUNTIME_CONTRACT_MANIFEST_VERSION = "noopolis.daimon.runtime-contract-manifest.v1" as const; -export const ENGINE_CREDENTIAL_MATERIAL = { - codex: { sourceSlot: "codex-auth", sourceRelativePath: ".daimon-inbound/codex-auth", destinationRelativePath: ".codex/auth.json", directoryMode: 0o700, fileMode: 0o600 }, - grok: { sourceSlot: "grok-auth", sourceRelativePath: ".daimon-inbound/grok-auth", destinationRelativePath: ".grok/auth.json", directoryMode: 0o700, fileMode: 0o600 } -} as const; -export const AGY_SUBSCRIPTION_REALM = { - durableMountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", - unlockMountPath: "/var/lib/spawnfile/daimon/agy-unlock-secret", - unlockSourceSlot: "agy-unlock-secret", - directoryMode: 0o700, - fileMode: 0o600, - maxUnlockBytes: 4_096 -} as const; - -const MAX_DURATION_MS = 180_000; -const text = { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" } as const; -const boundedText = { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS } as const; -const timestamp = { type: "string", format: "date-time", pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" } as const; -const wakeEvent = { type: "object", additionalProperties: false, required: ["version", "id", "kind", "text", "occurredAt"], properties: { version: { const: "noopolis.daimon.wake.v1" }, id: text, kind: { enum: ["manual", "message", "external"] }, text: boundedText, occurredAt: timestamp } } as const; -const wakeResultBase = { version: { const: "noopolis.daimon.wake-result.v1" }, agentId: text, wakeId: text } as const; -const activityItem = { type: "object", additionalProperties: false, required: ["id", "agentId", "kind", "occurredAt"], properties: { id: { type: "string", format: "uuid", pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" }, agentId: text, wakeId: text, kind: { enum: ["wake_started", "wake_completed", "wake_rejected", "wake_aborted", "agent_stopped"] }, occurredAt: timestamp } } as const; - -export const RUNTIME_CONTRACT_MANIFEST = { - version: RUNTIME_CONTRACT_MANIFEST_VERSION, - consumedConfigFields: ["version", "host.bindHost", "host.port", "host.controlTokenEnv", "agents[].id", "agents[].name", "agents[].instructions", "agents[].workspacePath", "agents[].runtimeHomePath", "agents[].engine.kind"], - organizationRuntimeConfigSchema: ORGANIZATION_RUNTIME_CONFIG_SCHEMA, - supportedEngineKinds: ["agy", "codex", "grok"], - engineCredentialMaterial: ENGINE_CREDENTIAL_MATERIAL, - agySubscriptionRealm: AGY_SUBSCRIPTION_REALM, - wakeRequestSchema: { type: "object", additionalProperties: false, required: ["agentId", "event"], properties: { agentId: text, event: wakeEvent } }, - wakeResultSchema: { oneOf: [ - { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "text", "durationMs"], properties: { ...wakeResultBase, status: { const: "completed" }, text: boundedText, durationMs: { type: "integer", minimum: 0, maximum: MAX_DURATION_MS } } }, - { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { ...wakeResultBase, status: { const: "rejected" }, code: { enum: ["unauthorized", "unknown_agent", "queue_full"] } } }, - { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { version: { const: "noopolis.daimon.wake-result.v1" }, status: { const: "rejected" }, agentId: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS }, wakeId: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS }, code: { const: "invalid_request" } } }, - { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { ...wakeResultBase, status: { const: "stopped" }, code: { enum: ["host_stopping", "host_stopped", "queued_wake_stopped", "active_wake_aborted"] } } }, - { type: "object", additionalProperties: false, required: ["version", "status", "agentId", "wakeId", "code"], properties: { ...wakeResultBase, status: { const: "failed" }, code: { const: "engine_failed" } } } - ] }, - healthResponseSchema: { type: "object", additionalProperties: false, required: ["version", "state", "agents"], properties: { version: { const: "noopolis.daimon.organization-runtime-health.v1" }, state: { enum: ["starting", "running", "stopping", "stopped"] }, agents: { type: "array", maxItems: ORGANIZATION_RUNTIME_MAX_AGENTS, items: { type: "object", additionalProperties: false, required: ["agentId", "state"], properties: { agentId: text, state: { enum: ["starting", "running", "stopping", "stopped", "idle", "failed"] } } } } } }, - activityResponseSchema: { type: "object", additionalProperties: false, required: ["version", "items"], properties: { version: { const: "noopolis.daimon.organization-runtime-activity.v1" }, items: { type: "array", maxItems: 100, items: activityItem }, nextCursor: { type: "string", minLength: 1, maxLength: 16, pattern: "^(0|[1-9][0-9]{0,15})$" } } } -} as const; +export { + AGY_SUBSCRIPTION_REALM, + canonicalJson, + ENGINE_CREDENTIAL_MATERIAL, + GROK_ENGINE_BROKER, + GROK_SUBSCRIPTION_REALM, + ORGANIZATION_RUNTIME_ACTIVITY_V2_VERSION, + RUNTIME_CONTRACT_MANIFEST, + RUNTIME_CONTRACT_MANIFEST_VERSION +}; export type RuntimeContractManifest = typeof RUNTIME_CONTRACT_MANIFEST; export type EngineCredentialKind = keyof typeof ENGINE_CREDENTIAL_MATERIAL; export type EngineCredentialSlot = (typeof ENGINE_CREDENTIAL_MATERIAL)[EngineCredentialKind]["sourceSlot"]; -export function canonicalRuntimeContractManifest(): string { return canonicalJson(RUNTIME_CONTRACT_MANIFEST); } - -export function canonicalJson(value: unknown): string { - if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - if (typeof value !== "object") throw new TypeError("manifest must contain only JSON data"); - const record = value as Record; - return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; -} +export const canonicalRuntimeContractManifest = (): string => canonicalJson(RUNTIME_CONTRACT_MANIFEST); diff --git a/src/runtime/contractManifest.types.test.ts b/src/runtime/contractManifest.types.test.ts new file mode 100644 index 0000000..add0833 --- /dev/null +++ b/src/runtime/contractManifest.types.test.ts @@ -0,0 +1,25 @@ +import { + ENGINE_CREDENTIAL_MATERIAL, + RUNTIME_CONTRACT_MANIFEST, + RUNTIME_CONTRACT_MANIFEST_VERSION +} from "./contractManifest.js"; + +type Assert = T; +type IsEqual = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? + ((() => T extends B ? 1 : 2) extends (() => T extends A ? 1 : 2) ? true : false) : false; + +type ManifestVersion = Assert>; +type CodexCredentialSlot = Assert>; + +const manifestVersion: ManifestVersion = true; +const codexCredentialSlot: CodexCredentialSlot = true; +void [manifestVersion, codexCredentialSlot]; + +function manifestConstantsAreReadonly(): void { + // @ts-expect-error The exported manifest remains deeply immutable. + RUNTIME_CONTRACT_MANIFEST.supportedEngineKinds[0] = "codex"; + // @ts-expect-error Credential material remains deeply immutable. + ENGINE_CREDENTIAL_MATERIAL.codex.sourceSlot = "codex-auth"; +} +void manifestConstantsAreReadonly; diff --git a/src/runtime/contractManifestArtifacts.test.ts b/src/runtime/contractManifestArtifacts.test.ts new file mode 100644 index 0000000..f66a263 --- /dev/null +++ b/src/runtime/contractManifestArtifacts.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; + +import { canonicalRuntimeContractManifest } from "./contractManifest.js"; + +const exec = promisify(execFile); +const manifestPath = path.resolve("dist/runtime/contract-manifest.json"); +const digestPath = path.resolve("dist/runtime/contract-manifest.sha256"); +const emitterPath = path.resolve("scripts/emitRuntimeContractManifest.mjs"); + +const npm = async (...args: string[]): Promise => { + const result = await exec("npm", args, { + cwd: process.cwd(), + encoding: "utf8", + env: { ...process.env, npm_config_cache: path.resolve(".runtime/npm-cache") }, + maxBuffer: 10 * 1024 * 1024 + }); + return result.stdout; +}; + +test("build emits canonical manifest artifacts, detects drift, and packs both files", async () => { + await npm("run", "build"); + const canonical = canonicalRuntimeContractManifest(); + const manifest = await readFile(manifestPath); + assert.deepEqual(manifest, Buffer.from(`${canonical}\n`, "utf8")); + + const hash = createHash("sha256").update(manifest).digest("hex"); + const digest = Buffer.from(`sha256:${hash}\n`, "ascii"); + assert.match(hash, /^[a-f0-9]{64}$/); + assert.deepEqual(await readFile(digestPath), digest); + const verify = (): ReturnType => exec(process.execPath, ["--import", "tsx", emitterPath, "--check"], { cwd: process.cwd() }); + await verify(); + + try { + await writeFile(manifestPath, "{}", "utf8"); + await assert.rejects( + verify(), + /emitted runtime contract artifacts drift from source constants/ + ); + await writeFile(manifestPath, manifest); + await writeFile(digestPath, "sha256:0000000000000000000000000000000000000000000000000000000000000000\n", "ascii"); + await assert.rejects( + verify(), + /emitted runtime contract artifacts drift from source constants/ + ); + } finally { + await npm("run", "build"); + } + assert.deepEqual(await readFile(manifestPath), manifest); + assert.deepEqual(await readFile(digestPath), digest); + + const packed = JSON.parse(await npm("pack", "--dry-run", "--ignore-scripts", "--json")) as Array<{ files: Array<{ path: string }> }>; + const paths = packed[0]?.files.map((entry) => entry.path); + assert.ok(paths?.includes("dist/runtime/contract-manifest.json")); + assert.ok(paths?.includes("dist/runtime/contract-manifest.sha256")); +}); From c4aec8c5b4d51264a8531cfa9a3ab9034d497757 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:24 +0200 Subject: [PATCH 03/24] feat(grok): secure subscription-backed CLI sessions --- src/core/credentialRedaction.test.ts | 17 + src/core/credentialRedaction.ts | 43 ++ src/mcp/toolServer.ts | 39 +- src/pi/cliChildOutput.ts | 130 ++++++ src/pi/cliEngineRun.ts | 2 +- src/pi/cliEngineSpawn.test.ts | 44 ++ src/pi/cliEngineSpawn.ts | 28 +- src/pi/cliEnvironment.test.ts | 9 +- src/pi/cliEnvironment.ts | 6 +- src/pi/cliSession.test.ts | 7 +- src/pi/cliSession.ts | 174 ++++---- src/pi/cliSessionOutput.test.ts | 161 +++++++ src/pi/cliSessionProcess.test.ts | 9 +- src/pi/cliSessionRemoval.test.ts | 49 ++- src/pi/grokHeadlessResult.test.ts | 124 ++++++ src/pi/grokHeadlessResult.ts | 66 +++ src/pi/grokSandbox.test.ts | 91 ++++ src/pi/grokSandbox.ts | 210 +++++++++ src/pi/turnTrace.ts | 10 +- src/runtime/grokAuthenticationError.ts | 27 ++ src/runtime/grokSubscriptionRealm.test.ts | 362 ++++++++++++++++ src/runtime/grokSubscriptionRealm.ts | 399 ++++++++++++++++++ src/runtime/grokSubscriptionRealmTypes.ts | 25 ++ src/runtime/portableCredentialAuth.ts | 84 ++++ .../portableCredentialMaterial.test.ts | 91 ++++ src/runtime/portableCredentialMaterial.ts | 194 +++++++++ 26 files changed, 2269 insertions(+), 132 deletions(-) create mode 100644 src/core/credentialRedaction.test.ts create mode 100644 src/core/credentialRedaction.ts create mode 100644 src/pi/cliChildOutput.ts create mode 100644 src/pi/cliEngineSpawn.test.ts create mode 100644 src/pi/cliSessionOutput.test.ts create mode 100644 src/pi/grokHeadlessResult.test.ts create mode 100644 src/pi/grokHeadlessResult.ts create mode 100644 src/pi/grokSandbox.test.ts create mode 100644 src/pi/grokSandbox.ts create mode 100644 src/runtime/grokAuthenticationError.ts create mode 100644 src/runtime/grokSubscriptionRealm.test.ts create mode 100644 src/runtime/grokSubscriptionRealm.ts create mode 100644 src/runtime/grokSubscriptionRealmTypes.ts create mode 100644 src/runtime/portableCredentialAuth.ts create mode 100644 src/runtime/portableCredentialMaterial.test.ts create mode 100644 src/runtime/portableCredentialMaterial.ts diff --git a/src/core/credentialRedaction.test.ts b/src/core/credentialRedaction.test.ts new file mode 100644 index 0000000..104a02f --- /dev/null +++ b/src/core/credentialRedaction.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { redactCredentialText } from "./credentialRedaction.js"; + +test("redacts Grok key, refresh/access values, exact rotations, and bounds UTF-8", () => { + const oldSecret = "raw-old-access-secret-123456789"; + const rotatedSecret = "raw-rotated-refresh-secret-987654321"; + const result = redactCredentialText(JSON.stringify({ + key: oldSecret, + refresh_token: rotatedSecret, + note: `also ${oldSecret}` + }), [oldSecret, rotatedSecret], 256); + assert.doesNotMatch(result, /raw-old|raw-rotated/u); + assert.match(result, /\[REDACTED\]/u); + assert.ok(Buffer.byteLength(redactCredentialText("😀".repeat(100), [], 13), "utf8") <= 13); +}); diff --git a/src/core/credentialRedaction.ts b/src/core/credentialRedaction.ts new file mode 100644 index 0000000..468f2c1 --- /dev/null +++ b/src/core/credentialRedaction.ts @@ -0,0 +1,43 @@ +const DEFAULT_MAX_BYTES = 16_384; + +const truncateUtf8 = (value: string, maxBytes: number): string => { + if (Buffer.byteLength(value, "utf8") <= maxBytes) return value; + let result = Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8"); + while (result.endsWith("\uFFFD")) result = result.slice(0, -1); + return result; +}; + +/** Redacts provider credentials before text crosses any reply or diagnostic boundary. */ +export function redactCredentialText( + value: unknown, + exactSecrets: readonly string[] = [], + maxBytes: number = DEFAULT_MAX_BYTES +): string { + let result = String(value); + for (const secret of [...new Set(exactSecrets.filter((entry) => entry.length > 0))] + .sort((left, right) => right.length - left.length)) { + result = result.split(secret).join("[REDACTED]"); + } + result = result + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/giu, "Bearer [REDACTED]") + .replace(/\bmagt_v1_[A-Za-z0-9_-]{16,}\b/gu, "[REDACTED]") + .replace(/\b(?:sk|sk-proj|xai)-[A-Za-z0-9_-]{20,}\b/gu, "[REDACTED]") + .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu, "[REDACTED]") + .replace( + /("(?:api[_-]?key|authorization|client[_-]?secret|credential|key|password|secret|(?:access|refresh|id)[_-]?token|token)"\s*:\s*")([^"\\]*(?:\\.[^"\\]*)*)(")/giu, + "$1[REDACTED]$3" + ) + .replace( + /\b(api[_-]?key|authorization|client[_-]?secret|credential|password|secret|(?:access|refresh|id)[_-]?token|token)\b(\s*[:=]\s*)(["']?)[^\s"',&]{8,}\3/giu, + "$1$2[REDACTED]" + ); + return truncateUtf8(result, maxBytes); +} + +export function redactCredentialError( + error: unknown, + exactSecrets: readonly string[] = [], + maxBytes: number = DEFAULT_MAX_BYTES +): Error { + return new Error(redactCredentialText(error instanceof Error ? error.message : error, exactSecrets, maxBytes)); +} diff --git a/src/mcp/toolServer.ts b/src/mcp/toolServer.ts index d7bb713..677735c 100644 --- a/src/mcp/toolServer.ts +++ b/src/mcp/toolServer.ts @@ -28,8 +28,8 @@ export class McpWakeDeadlineError extends Error { } export interface PiToolMcpServerOptions { - readonly maxToolTurns: number; - readonly wakeDeadline: number; + readonly maxToolTurns?: number; + readonly wakeDeadline?: number; } type JsonSchema = Record; @@ -59,10 +59,10 @@ const toolError = (error: unknown): CallToolResult => ({ const NO_PI_EXTENSION_CONTEXT = undefined as never; const validateOptions = (options: PiToolMcpServerOptions): void => { - if (!Number.isSafeInteger(options.maxToolTurns) || options.maxToolTurns < 1) { + if (options.maxToolTurns !== undefined && (!Number.isSafeInteger(options.maxToolTurns) || options.maxToolTurns < 1)) { throw new TypeError("maxToolTurns must be a positive safe integer"); } - if (!Number.isFinite(options.wakeDeadline)) { + if (options.wakeDeadline !== undefined && !Number.isFinite(options.wakeDeadline)) { throw new TypeError("wakeDeadline must be a finite epoch-millisecond deadline"); } }; @@ -92,8 +92,8 @@ export const createPiToolMcpServer = ( CallToolRequestSchema, async (request, extra: RequestHandlerExtra) => { try { - if (Date.now() >= options.wakeDeadline) throw new McpWakeDeadlineError(); - if (toolTurns >= options.maxToolTurns) throw new McpToolTurnLimitError(options.maxToolTurns); + if (options.wakeDeadline !== undefined && Date.now() >= options.wakeDeadline) throw new McpWakeDeadlineError(); + if (options.maxToolTurns !== undefined && toolTurns >= options.maxToolTurns) throw new McpToolTurnLimitError(options.maxToolTurns); const tool = tools.find((candidate) => candidate.name === request.params.name); const validator = validators.get(request.params.name); if (tool === undefined || validator === undefined) { @@ -106,29 +106,20 @@ export const createPiToolMcpServer = ( toolTurns += 1; // Ajv validated this value against this tool's own schema immediately above. const validatedArgs = args as Parameters[1]; - const deadlineController = new AbortController(); - const remainingMs = Math.max(0, options.wakeDeadline - Date.now()); - const deadlineTimer = setTimeout(() => deadlineController.abort(), remainingMs); - const signal = extra.signal === undefined - ? deadlineController.signal - : AbortSignal.any([extra.signal, deadlineController.signal]); - const deadline = new Promise((_resolve, reject) => { + const deadlineController = options.wakeDeadline === undefined ? undefined : new AbortController(); + const remainingMs = options.wakeDeadline === undefined ? undefined : Math.max(0, options.wakeDeadline - Date.now()); + const deadlineTimer = deadlineController === undefined ? undefined : setTimeout(() => deadlineController.abort(), remainingMs); + const signal = deadlineController === undefined ? extra.signal + : extra.signal === undefined ? deadlineController.signal : AbortSignal.any([extra.signal, deadlineController.signal]); + const deadline = deadlineController === undefined ? undefined : new Promise((_resolve, reject) => { deadlineController.signal.addEventListener("abort", () => reject(new McpWakeDeadlineError()), { once: true }); }); let result: Awaited>; try { - result = await Promise.race([ - tool.execute( - `mcp-tool-turn-${toolTurns}`, - validatedArgs, - signal, - undefined, - NO_PI_EXTENSION_CONTEXT - ), - deadline - ]); + const execution = tool.execute(`mcp-tool-turn-${toolTurns}`, validatedArgs, signal, undefined, NO_PI_EXTENSION_CONTEXT); + result = deadline === undefined ? await execution : await Promise.race([execution, deadline]); } finally { - clearTimeout(deadlineTimer); + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); } return toolResult(result); } catch (error) { diff --git a/src/pi/cliChildOutput.ts b/src/pi/cliChildOutput.ts new file mode 100644 index 0000000..f355e7e --- /dev/null +++ b/src/pi/cliChildOutput.ts @@ -0,0 +1,130 @@ +import type { ChildProcess } from "node:child_process"; + +import { redactCredentialText } from "../core/credentialRedaction.js"; +import { terminateChild, trackCliChild } from "./cliProcess.js"; + +/** Maximum assistant reply bytes retained from stdout. */ +export const CLI_ENGINE_MAX_OUTPUT_BYTES = 64 * 1024; +/** Tail bytes retained from stderr only for a failed-child diagnostic. */ +export const CLI_ENGINE_MAX_DIAGNOSTIC_BYTES = 768; +const CLI_ENGINE_FAILURE_SCAN_CHARS = 256; + +const redactChildOutput = (value: string, secretValues: readonly string[]): string => { + return redactCredentialText(value, secretValues, CLI_ENGINE_MAX_OUTPUT_BYTES); +}; + +const utf8Tail = (value: string, maxBytes: number): string => { + const bytes = Buffer.from(value, "utf8"); + if (bytes.length <= maxBytes) return value; + let result = bytes.subarray(bytes.length - maxBytes).toString("utf8"); + while (result.startsWith("\uFFFD")) result = result.slice(1); + return result; +}; + +const childDiagnostic = (stdout: string, stderr: string, secretValues: readonly string[]): string => { + const output = stderr.trim().length > 0 ? stderr : stdout; + const redacted = redactCredentialText(output, secretValues, Number.MAX_SAFE_INTEGER).trim(); + const bounded = utf8Tail(redacted, CLI_ENGINE_MAX_DIAGNOSTIC_BYTES).trim(); + return bounded.length > 0 ? `: ${bounded}` : ""; +}; + +export const readChild = ( + child: ChildProcess, + timeoutMs: number | undefined, + secretValues: readonly string[], + options: Readonly<{ + failureClassifier?: (diagnostic: string) => Error | undefined; + retainStdoutTail?: boolean; + }> = {} +): Promise => new Promise((resolve, reject) => { + trackCliChild(child); + const stdout: Buffer[] = []; + let stdoutTail = Buffer.alloc(0); + let droppingStdoutLine = false; + let stderrTail = Buffer.alloc(0); + let stdoutBytes = 0; + let settled = false; + let cleanupStarted = false; + let classifiedFailure: Error | undefined; + let failureScanTail = ""; + const stderrRetentionBytes = CLI_ENGINE_MAX_DIAGNOSTIC_BYTES + + Math.max(0, ...secretValues.map((secret) => Buffer.byteLength(secret, "utf8"))); + const settle = (action: () => void): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + action(); + }; + const abort = (error: Error): void => { + if (cleanupStarted) return; + cleanupStarted = true; + void terminateChild(child).then( + () => settle(() => reject(error)), + (cleanupError: unknown) => settle(() => reject(cleanupError instanceof Error ? cleanupError : error)) + ); + }; + const classifyFailure = (chunk: Buffer): void => { + if (classifiedFailure !== undefined || options.failureClassifier === undefined) return; + const diagnostic = `${failureScanTail}${chunk.toString("utf8")}`; + classifiedFailure = options.failureClassifier(diagnostic); + failureScanTail = diagnostic.slice(-CLI_ENGINE_FAILURE_SCAN_CHARS); + }; + const retainStdout = (chunk: Buffer): void => { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + classifyFailure(value); + if (options.retainStdoutTail === true) { + let remainder = value; + if (droppingStdoutLine) { + const newline = remainder.indexOf(0x0a); + if (newline < 0) return; + droppingStdoutLine = false; + remainder = remainder.subarray(newline + 1); + } + const combined = Buffer.concat([stdoutTail, remainder]); + if (combined.length <= CLI_ENGINE_MAX_OUTPUT_BYTES) { + stdoutTail = combined; + return; + } + const overflow = combined.length - CLI_ENGINE_MAX_OUTPUT_BYTES; + if (combined[overflow - 1] === 0x0a) { + stdoutTail = Buffer.from(combined.subarray(overflow)); + return; + } + const newline = combined.indexOf(0x0a, overflow); + stdoutTail = newline < 0 ? Buffer.alloc(0) : Buffer.from(combined.subarray(newline + 1)); + droppingStdoutLine = newline < 0; + return; + } + stdoutBytes += value.length; + if (stdoutBytes > CLI_ENGINE_MAX_OUTPUT_BYTES) { + abort(new Error(`CLI engine output exceeded ${CLI_ENGINE_MAX_OUTPUT_BYTES} bytes`)); + return; + } + stdout.push(value); + }; + const retainStderrTail = (chunk: Buffer): void => { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + classifyFailure(value); + if (value.length >= stderrRetentionBytes) { + stderrTail = Buffer.from(value.subarray(value.length - stderrRetentionBytes)); + return; + } + const overflow = stderrTail.length + value.length - stderrRetentionBytes; + stderrTail = Buffer.concat([overflow > 0 ? stderrTail.subarray(overflow) : stderrTail, value]); + }; + child.stdout?.on("data", retainStdout); + child.stderr?.on("data", retainStderrTail); + const timer = timeoutMs === undefined ? undefined : setTimeout(() => abort(new Error("CLI engine timed out")), timeoutMs); + child.once("error", abort); + child.once("close", (code, signal) => { + if (cleanupStarted) return; + const retainedStdout = options.retainStdoutTail === true ? stdoutTail : Buffer.concat(stdout); + if (code === 0) { + settle(() => resolve(redactChildOutput(retainedStdout.toString("utf8"), secretValues).trim())); + return; + } + settle(() => reject(classifiedFailure ?? new Error(`CLI engine exited ${code ?? signal}${childDiagnostic( + retainedStdout.toString("utf8"), stderrTail.toString("utf8"), secretValues + )}`))); + }); +}); diff --git a/src/pi/cliEngineRun.ts b/src/pi/cliEngineRun.ts index 17a2be0..73f439e 100644 --- a/src/pi/cliEngineRun.ts +++ b/src/pi/cliEngineRun.ts @@ -15,7 +15,7 @@ export const runEngineDetailed = async ( const startedAt = Date.now(); const options: CliEngineOptions = engine === "agy" ? { engine, maxToolTurns: 1, timeoutMs: 180_000, toolAccess: "none" } - : { engine, maxToolTurns: 2, timeoutMs: 180_000 }; + : { engine }; const { session } = await createCliSessionFactory(options)({ cwd: paths.workspacePath, runtimeHomePath: paths.runtimeHomePath diff --git a/src/pi/cliEngineSpawn.test.ts b/src/pi/cliEngineSpawn.test.ts new file mode 100644 index 0000000..4282571 --- /dev/null +++ b/src/pi/cliEngineSpawn.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { readChild } from "./cliSession.js"; +import { GROK_STRICT_SANDBOX_PROFILE, renderGrokSandboxArgs, spawnEngine } from "./cliEngineSpawn.js"; + +test("autonomous Codex and Grok launches omit wall-clock and turn caps", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-unbounded-cli-")); + const command = path.join(root, "engine"); + await writeFile(command, "#!/usr/bin/env node\nconst a=process.argv.slice(2);if(a[0]==='mcp')process.stdout.write('ok');else{for await(const c of process.stdin){}process.stdout.write(JSON.stringify(a));}\n"); + await chmod(command, 0o700); + try { + for (const engine of ["codex", "grok"] as const) { + const options = engine === "grok" + ? { engine, command, commandArgs: ["--deny", "Bash(rm *)"], maxToolTurns: 1 } + : { engine, command }; + const child = spawnEngine(options, "probe", { cwd: root }, "http://127.0.0.1:1234/mcp"); + const args = JSON.parse(await readChild(child, 10_000, [])) as string[]; + assert.equal(args.includes("--max-turns"), false); + assert.equal(args.includes("--print-timeout"), false); + if (engine === "grok") { + assert.ok(args.includes("--always-approve")); + assert.deepEqual(args.slice(0, 2), ["--deny", "Bash(rm *)"]); + assert.deepEqual(args.slice(args.indexOf("--sandbox"), args.indexOf("--sandbox") + 2), ["--sandbox", GROK_STRICT_SANDBOX_PROFILE]); + assert.ok(args.includes("--no-subagents")); + assert.deepEqual(args.slice(args.indexOf("--output-format"), args.indexOf("--output-format") + 2), ["--output-format", "streaming-messages-json"]); + assert.equal(args.includes("--json-schema"), false); + } + } + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("Grok's kernel sandbox authority cannot be weakened by injected CLI arguments", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-boundary-")); + try { + for (const commandArgs of [["--sandbox", "off"], ["--sandbox=workspace"], ["--permission-mode", "bypassPermissions"]]) { + assert.throws(() => spawnEngine({ engine: "grok", command: process.execPath, commandArgs }, "probe", { cwd: root }, undefined), /Daimon-owned/u); + } + assert.deepEqual(renderGrokSandboxArgs(undefined, "daimon-strict"), ["--sandbox", "daimon-strict"]); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/pi/cliEngineSpawn.ts b/src/pi/cliEngineSpawn.ts index 048e8b4..9adf28f 100644 --- a/src/pi/cliEngineSpawn.ts +++ b/src/pi/cliEngineSpawn.ts @@ -4,6 +4,13 @@ import { trackCliChild } from "./cliProcess.js"; import { cliChildEnvironment } from "./cliEnvironment.js"; import type { CliEngineOptions, CliSessionInput } from "./cliSession.js"; +export const GROK_STRICT_SANDBOX_PROFILE = "strict"; + +export const renderGrokSandboxArgs = ( + commandArgs: readonly string[] | undefined, + profile: string +): string[] => [...assertSafeGrokCommandArgs(commandArgs), "--sandbox", profile]; + export const renderCodexArgs = ( options: Pick, cwd: string, @@ -16,7 +23,8 @@ export const spawnEngine = ( options: CliEngineOptions, prompt: string, input: CliSessionInput, - endpoint: string | undefined + endpoint: string | undefined, + wakeId?: string ): ChildProcess => { const command = options.command ?? options.engine; const env = cliChildEnvironment([ @@ -26,7 +34,8 @@ export const spawnEngine = ( dbusSessionBusAddress: options.engine === "agy" ? options.dbusSessionBusAddress : undefined, engine: options.engine, executablePath: options.command, - engineHomePath: options.engineHomePath + engineHomePath: options.engineHomePath, + wakeId }); if (options.engine === "codex") { const child = trackCliChild(spawn(command, renderCodexArgs(options, input.cwd, endpoint), { cwd: input.cwd, env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] })); @@ -34,8 +43,19 @@ export const spawnEngine = ( child.stdin!.write(prompt); child.stdin!.end(); return child; } + // Production supplies a Daimon-owned custom profile whose enforcement and + // explicit realm/peer denies are verified before every Grok process. const args = options.engine === "grok" - ? [...(options.commandArgs ?? []), "--single", prompt, "--max-turns", String(options.maxToolTurns), "--no-memory", "--disable-web-search", "--cwd", input.cwd, "--output-format", "plain"] - : [...(options.commandArgs ?? []), "--print", prompt, "--print-timeout", `${options.timeoutMs}ms`]; + ? [...renderGrokSandboxArgs(options.commandArgs, options.grokSandboxProfile ?? GROK_STRICT_SANDBOX_PROFILE), "--always-approve", "--no-subagents", "--single", prompt, "--no-memory", "--disable-web-search", "--cwd", input.cwd, + "--output-format", "streaming-messages-json"] + : [...(options.commandArgs ?? []), "--print", prompt, ...(options.timeoutMs === undefined ? [] : ["--print-timeout", `${options.timeoutMs}ms`])]; return trackCliChild(spawn(command, args, { cwd: input.cwd, env, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] })); }; + +const assertSafeGrokCommandArgs = (args: readonly string[] | undefined): readonly string[] => { + const values = args ?? []; + if (values.some((value) => /^(?:--sandbox|--always-approve|--permission-mode|--leader-socket)(?:=|$)/u.test(value))) { + throw new Error("Grok security-boundary arguments are Daimon-owned"); + } + return values; +}; diff --git a/src/pi/cliEnvironment.test.ts b/src/pi/cliEnvironment.test.ts index 0746439..7a3badd 100644 --- a/src/pi/cliEnvironment.test.ts +++ b/src/pi/cliEnvironment.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { cliChildEnvironment } from "./cliEnvironment.js"; +import { cliChildEnvironment, DAIMON_WAKE_ID_ENV } from "./cliEnvironment.js"; test("passes only an explicit local Linux Secret Service bus to AGY", () => { const previous = process.env.DBUS_SESSION_BUS_ADDRESS; @@ -17,3 +17,10 @@ test("passes only an explicit local Linux Secret Service bus to AGY", () => { else process.env.DBUS_SESSION_BUS_ADDRESS = previous; } }); + +test("adds only the explicitly bound current wake id", () => { + const absent = cliChildEnvironment([], "/runtime", { engine: "codex" }); + const bound = cliChildEnvironment([], "/runtime", { engine: "codex", wakeId: "moltnet:msg_1" }); + assert.equal(absent[DAIMON_WAKE_ID_ENV], undefined); + assert.equal(bound[DAIMON_WAKE_ID_ENV], "moltnet:msg_1"); +}); diff --git a/src/pi/cliEnvironment.ts b/src/pi/cliEnvironment.ts index b6891cf..f9bc73a 100644 --- a/src/pi/cliEnvironment.ts +++ b/src/pi/cliEnvironment.ts @@ -5,8 +5,11 @@ type CliEnvironmentIntent = Readonly<{ engine?: "codex" | "grok" | "agy"; executablePath?: string; engineHomePath?: string; + wakeId?: string; }>; +export const DAIMON_WAKE_ID_ENV = "DAIMON_WAKE_ID" as const; + /** Build a positive child environment; agent CLIs never inherit host secrets. */ export const cliChildEnvironment = ( _redactedNames: readonly string[], runtimeHomePath?: string, intent: CliEnvironmentIntent = {} @@ -34,7 +37,8 @@ export const cliChildEnvironment = ( XDG_CACHE_HOME: `${runtimeHomePath}/.cache`, TMPDIR: `${runtimeHomePath}/.tmp`, ...(engineVariable === undefined || engineHome === undefined ? {} : { [engineVariable]: engineHome }), - ...(keyringBus === undefined ? {} : { DBUS_SESSION_BUS_ADDRESS: keyringBus }) + ...(keyringBus === undefined ? {} : { DBUS_SESSION_BUS_ADDRESS: keyringBus }), + ...(intent.wakeId === undefined ? {} : { [DAIMON_WAKE_ID_ENV]: intent.wakeId }) }; }; diff --git a/src/pi/cliSession.test.ts b/src/pi/cliSession.test.ts index c20ff9d..d331f37 100644 --- a/src/pi/cliSession.test.ts +++ b/src/pi/cliSession.test.ts @@ -194,7 +194,7 @@ test("CLI engine failures include bounded redacted diagnostics", async () => { } }); -test("CLI output cap counts combined multibyte stdout and stderr and quiesces descendants", async (context) => { +test("CLI stdout cap counts multibyte replies and quiesces descendants", async (context) => { if (!requirePosixProcessGroups(context)) return; const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-output-cap-")); const pidFile = path.join(root, "descendant.pid"); @@ -258,7 +258,7 @@ test("protected host control variables never reach Codex, Grok, or AGY children" process.env[unrelatedEnv] = "must-never-reach-engine"; process.env[modelEnv] = "must-never-reach-engine"; const probe = path.join(root, "probe.mjs"); - await writeFile(probe, `#!/usr/bin/env node\nprocess.stdout.write([process.env.${controlEnv} ?? "absent", process.env.${unrelatedEnv} ?? "absent", process.env.${modelEnv} ?? "absent", process.env.CODEX_HOME ?? process.env.GROK_HOME ?? process.env.ANTIGRAVITY_CLI_HOME ?? "missing"].join("|"));`); + await writeFile(probe, `#!/usr/bin/env node\nconst text = [process.env.${controlEnv} ?? "absent", process.env.${unrelatedEnv} ?? "absent", process.env.${modelEnv} ?? "absent", process.env.CODEX_HOME ?? process.env.GROK_HOME ?? process.env.ANTIGRAVITY_CLI_HOME ?? "missing", process.env.DAIMON_WAKE_ID ?? "absent"].join("|"); const stream = (value) => [{ type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: value }] } }, { type: "result", subtype: "success", is_error: false, result: value, stop_reason: "end_turn", session_id: "fake" }].map(JSON.stringify).join("\\n"); process.stdout.write(process.argv.includes("--single") ? stream(text) : text);`); await chmod(probe, 0o700); try { for (const engine of ["codex", "grok", "agy"] as const) { @@ -267,6 +267,7 @@ test("protected host control variables never reach Codex, Grok, or AGY children" ? { engine, command: probe, commandArgs: [], maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" as const, redactedEnvironmentNames: [controlEnv], engineHomePath } : { engine, command: probe, commandArgs: [], maxToolTurns: 1, timeoutMs: 10_000, redactedEnvironmentNames: [controlEnv], engineHomePath }; const { session } = await createCliSessionFactory(options)({ cwd: root, runtimeHomePath: path.join(root, engine) }); + session.bindWake?.({ id: "moltnet:msg_1", kind: "message", text: "probe" }); let output = ""; session.subscribe((event) => { if (event.type === "turn_end" && "content" in event.message && Array.isArray(event.message.content)) { @@ -274,7 +275,7 @@ test("protected host control variables never reach Codex, Grok, or AGY children" } }); await session.prompt("probe"); - assert.equal(output, `absent|absent|absent|${engineHomePath}`); + assert.equal(output, `absent|absent|absent|${engineHomePath}|moltnet:msg_1`); await session.disposeAsync?.(); } } finally { diff --git a/src/pi/cliSession.ts b/src/pi/cliSession.ts index 0b276c3..12703da 100644 --- a/src/pi/cliSession.ts +++ b/src/pi/cliSession.ts @@ -7,18 +7,28 @@ import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { createPiToolMcpServer } from "../mcp/toolServer.js"; +import type { WakeEvent } from "../core/types.js"; +import { redactCredentialError, redactCredentialText } from "../core/credentialRedaction.js"; +import { + asGrokAuthenticationRejected, + classifyGrokAuthenticationDiagnostic, + GrokSubscriptionAuthenticationRejectedError +} from "../runtime/grokAuthenticationError.js"; +import { readChild } from "./cliChildOutput.js"; import { cliChildEnvironment } from "./cliEnvironment.js"; -import { renderCodexArgs, spawnEngine } from "./cliEngineSpawn.js"; +import { + GROK_STRICT_SANDBOX_PROFILE, + renderCodexArgs, + renderGrokSandboxArgs, + spawnEngine +} from "./cliEngineSpawn.js"; +import { decodeGrokHeadlessResult } from "./grokHeadlessResult.js"; import { terminateChild, trackCliChild } from "./cliProcess.js"; import type { PiSessionLike } from "./piAgentHandle.js"; import type { PiSessionFactoryInput } from "./piHarness.js"; -import { redactTraceText } from "./turnTrace.js"; export type CliEngineKind = "agy" | "codex" | "grok"; -/** Total stdout + stderr retained for one CLI invocation. */ -export const CLI_ENGINE_MAX_OUTPUT_BYTES = 64 * 1024; - export type CliEngineOptions = { readonly commandArgs?: readonly string[]; readonly command?: string; @@ -26,12 +36,18 @@ export type CliEngineOptions = { readonly verifyExecutable?: () => Promise; readonly verifyRuntimePaths?: () => Promise; readonly engineHomePath?: string; - readonly maxToolTurns: number; + readonly maxToolTurns?: number; readonly onToolsMounted?: (tools: readonly ToolDefinition[]) => void; - readonly timeoutMs: number; + readonly timeoutMs?: number; /** Daimon-owned identity envelope prepended exactly once to every wake. */ readonly identityPrompt?: string; readonly redactedEnvironmentNames?: readonly string[]; + /** Internal secret-file authority used only for exact reply redaction. */ + readonly credentialSecretValues?: () => Promise; + /** Internal production boundary; confirms the custom kernel profile before every Grok process. */ + readonly verifyGrokSandbox?: () => Promise; + readonly grokSandboxProfile?: string; + readonly grokBrokerTurn?: (prompt: string, mcpEndpoint: string, signal: AbortSignal) => Promise; } & ({ readonly engine: "codex" | "grok"; } | { @@ -70,81 +86,18 @@ const childSecretValues = (redactedNames: readonly string[]): readonly string[] .map((name) => process.env[name]) .filter((value): value is string => typeof value === "string" && value.length > 0); -const redactChildOutput = (value: string, secretValues: readonly string[]): string => { - let redacted = redactTraceText(value); - for (const secret of secretValues) redacted = redacted.split(secret).join("[REDACTED]"); - return redacted; -}; - -const childDiagnostic = (stdout: string, stderr: string, secretValues: readonly string[]): string => { - const output = stderr.trim().length > 0 ? stderr : stdout; - const redacted = redactChildOutput(output, secretValues).trim(); - return redacted.length > 0 ? `: ${redacted}` : ""; -}; - const captureCleanup = async (current: unknown, action: () => Promise): Promise => { try { await action(); } catch (error) { return current ?? error; } return current; }; export { terminateChild } from "./cliProcess.js"; - -export const readChild = (child: ChildProcess, timeoutMs: number, secretValues: readonly string[]): Promise => new Promise((resolve, reject) => { - trackCliChild(child); - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - let bytes = 0; - let settled = false; - let cleanupStarted = false; - const settle = (action: () => void): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - action(); - }; - const abort = (error: Error): void => { - if (cleanupStarted) return; - cleanupStarted = true; - void terminateChild(child).then( - () => settle(() => reject(error)), - (cleanupError: unknown) => settle(() => reject(cleanupError instanceof Error ? cleanupError : error)) - ); - }; - const retain = (target: Buffer[], chunk: Buffer): void => { - const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - bytes += value.length; - if (bytes > CLI_ENGINE_MAX_OUTPUT_BYTES) { - abort(new Error(`CLI engine output exceeded ${CLI_ENGINE_MAX_OUTPUT_BYTES} bytes`)); - return; - } - target.push(value); - }; - child.stdout?.on("data", (chunk: Buffer) => retain(stdout, chunk)); - child.stderr?.on("data", (chunk: Buffer) => retain(stderr, chunk)); - const timer = setTimeout(() => { - abort(new Error("CLI engine timed out")); - }, timeoutMs); - child.once("error", (error) => { - abort(error); - }); - child.once("close", (code, signal) => { - if (cleanupStarted) return; - if (code === 0) { - settle(() => resolve(Buffer.concat(stdout).toString("utf8").trim())); - } else { - settle(() => reject(new Error(`CLI engine exited ${code ?? signal}${childDiagnostic( - Buffer.concat(stdout).toString("utf8"), - Buffer.concat(stderr).toString("utf8"), - secretValues - )}`))); - } - }); -}); +export { CLI_ENGINE_MAX_DIAGNOSTIC_BYTES, CLI_ENGINE_MAX_OUTPUT_BYTES, readChild } from "./cliChildOutput.js"; const startMcp = async ( tools: ToolDefinition[], - maxToolTurns: number, - wakeDeadline: number, + maxToolTurns: number | undefined, + wakeDeadline: number | undefined, onToolsMounted: ((tools: readonly ToolDefinition[]) => void) | undefined, onStarted: (mount: { endpoint: string; close: () => Promise }) => void ): Promise<{ endpoint: string; close: () => Promise }> => { @@ -219,7 +172,9 @@ const grokCommand = async ( })); onChild(child); try { - await readChild(child, 30_000, secretValues); + await readChild(child, 30_000, secretValues, { + failureClassifier: classifyGrokAuthenticationDiagnostic + }); } finally { // The Grok CLI may let an auxiliary process outlive its leader. Do not // release the tracked setup child until its detached group is quiescent. @@ -230,18 +185,23 @@ const grokCommand = async ( const addGrokServer = async ( endpoint: string, cwd: string, env: NodeJS.ProcessEnv, command: string, commandArgs: readonly string[], secretValues: readonly string[], - onChild: (child: ChildProcess) => void, onChildSettled: (child: ChildProcess) => void + onChild: (child: ChildProcess) => void, onChildSettled: (child: ChildProcess) => void, + profile: string, verifySandbox: (() => Promise) | undefined ): Promise => { + await verifySandbox?.(); await grokCommand( - [...commandArgs, "mcp", "add", "--transport", "http", "--scope", "project", "daimon", endpoint], + [...renderGrokSandboxArgs(commandArgs, profile), "mcp", "add", "--transport", "http", "--scope", "project", "daimon", endpoint], cwd, env, command, secretValues, onChild, onChildSettled ); let closePromise: Promise | undefined; return { - close: (): Promise => closePromise ??= grokCommand( - [...commandArgs, "mcp", "remove", "--scope", "project", "daimon"], - cwd, env, command, secretValues, onChild, onChildSettled - ) + close: (): Promise => closePromise ??= (async () => { + await verifySandbox?.(); + await grokCommand( + [...renderGrokSandboxArgs(commandArgs, profile), "mcp", "remove", "--scope", "project", "daimon"], + cwd, env, command, secretValues, onChild, onChildSettled + ); + })() }; }; @@ -251,8 +211,10 @@ class CliSession implements PiSessionLike { private disposed = false; private activeChild: ChildProcess | undefined; private activeMount: { close: () => Promise } | undefined; + private activeBrokerTurn: AbortController | undefined; private grokRegistration: GrokRegistration | undefined; private disposePromise: Promise | undefined; + private wakeId: string | undefined; public constructor( private readonly options: CliEngineOptions, @@ -264,20 +226,27 @@ class CliSession implements PiSessionLike { return () => this.listeners.delete(listener); } + public bindWake(event?: WakeEvent): void { + this.wakeId = event?.id; + } + public async prompt(text: string): Promise { if (this.disposed) throw new Error("CLI session is disposed"); await prepareCliRuntimeHome(this.input.runtimeHomePath); - const deadline = Date.now() + this.options.timeoutMs; - const secretValues = childSecretValues([ + const deadline = this.options.timeoutMs === undefined ? undefined : Date.now() + this.options.timeoutMs; + const environmentSecretValues = childSecretValues([ ...(this.options.redactedEnvironmentNames ?? []), ...(this.input.daimonSecretEnvironmentNames ?? []) ]); + const stagedCredentialSecrets = await this.options.credentialSecretValues?.() ?? []; + const secretValues = [...environmentSecretValues, ...stagedCredentialSecrets]; const needsMcp = this.options.engine !== "agy"; let mount: { endpoint: string; close: () => Promise } | undefined; let registration: GrokRegistration | undefined; let child: ChildProcess | undefined; let output: string | undefined; let cleanupFailure: unknown; + let promptFailure: unknown; try { mount = needsMcp ? await startMcp(this.input.customTools ?? [], this.options.maxToolTurns, deadline, this.options.onToolsMounted, (started) => { @@ -286,6 +255,10 @@ class CliSession implements PiSessionLike { }) : undefined; this.ensureLive(); + if (this.options.engine === "grok" && this.options.grokBrokerTurn !== undefined && mount !== undefined) { + const controller=new AbortController();this.activeBrokerTurn=controller; + try{output=await this.options.grokBrokerTurn(`${this.options.identityPrompt ?? ""}${text}`,mount.endpoint,controller.signal);}finally{if(this.activeBrokerTurn===controller)this.activeBrokerTurn=undefined;} + } else { if (this.options.engine === "grok" && mount !== undefined) { await this.options.verifyExecutable?.(); registration = await addGrokServer(mount.endpoint, this.input.cwd, cliChildEnvironment([ @@ -293,20 +266,30 @@ class CliSession implements PiSessionLike { ...(this.input.daimonSecretEnvironmentNames ?? []) ], this.input.runtimeHomePath, { engine: this.options.engine, executablePath: this.options.command, engineHomePath: this.options.engineHomePath }), this.options.command ?? "grok", this.options.commandArgs ?? [], secretValues, (setupChild) => { this.setupChildren.add(setupChild); - }, (setupChild) => this.setupChildren.delete(setupChild)); + }, (setupChild) => this.setupChildren.delete(setupChild), + this.options.grokSandboxProfile ?? GROK_STRICT_SANDBOX_PROFILE, + this.options.verifyGrokSandbox); this.grokRegistration = registration; } this.ensureLive(); await this.options.verifyRuntimePaths?.(); await this.options.verifyExecutable?.(); - child = spawnEngine(this.options, `${this.options.identityPrompt ?? ""}${text}`, this.input, mount?.endpoint); + await this.options.verifyGrokSandbox?.(); + child = spawnEngine(this.options, `${this.options.identityPrompt ?? ""}${text}`, this.input, mount?.endpoint, this.wakeId); this.activeChild = child; // Attach terminal listeners synchronously. A fast local sentinel may // exit before an asynchronous post-spawn authority recheck completes. - const outputPromise = readChild(child, Math.max(1, deadline - Date.now()), secretValues); + const outputPromise = readChild(child, deadline === undefined ? undefined : Math.max(1, deadline - Date.now()), secretValues, { + failureClassifier: this.options.engine === "grok" ? classifyGrokAuthenticationDiagnostic : undefined, + retainStdoutTail: this.options.engine === "grok" + }); await this.options.verifyRuntimePaths?.(); await this.options.verifyExecutable?.(); - output = await outputPromise; + const childOutput = await outputPromise; + output = this.options.engine === "grok" ? decodeGrokHeadlessResult(childOutput) : childOutput; + } + } catch (error) { + promptFailure = error; } finally { cleanupFailure = await captureCleanup(cleanupFailure, async () => { if (child !== undefined) await terminateChild(child); }); if (this.activeChild === child) this.activeChild = undefined; @@ -315,9 +298,25 @@ class CliSession implements PiSessionLike { if (registrationClosed && this.grokRegistration === registration) this.grokRegistration = undefined; cleanupFailure = await captureCleanup(cleanupFailure, async () => { await mount?.close(); }); if (this.activeMount === mount) this.activeMount = undefined; - if (cleanupFailure !== undefined) throw cleanupFailure; + } + const refreshedCredentialSecrets = await this.options.credentialSecretValues?.().catch(() => []) ?? []; + const finalSecrets = [...secretValues, ...refreshedCredentialSecrets]; + if (promptFailure !== undefined) { + const authRejection = this.options.engine === "grok" ? asGrokAuthenticationRejected(promptFailure) : undefined; + if (authRejection !== undefined) throw new GrokSubscriptionAuthenticationRejectedError( + cleanupFailure === undefined ? undefined : redactCredentialError(cleanupFailure, finalSecrets) + ); + if (cleanupFailure !== undefined) throw redactCredentialError(cleanupFailure, finalSecrets); + throw redactCredentialError(promptFailure, finalSecrets); + } + if (cleanupFailure !== undefined) { + if (this.options.engine === "grok" && asGrokAuthenticationRejected(cleanupFailure) !== undefined) { + throw new GrokSubscriptionAuthenticationRejectedError(); + } + throw redactCredentialError(cleanupFailure, finalSecrets); } if (output === undefined) return; + output = redactCredentialText(output, finalSecrets, 64 * 1024); for (const listener of this.listeners) listener({ type: "turn_end", message: { @@ -346,6 +345,7 @@ class CliSession implements PiSessionLike { private async quiesce(): Promise { let cleanupFailure: unknown; cleanupFailure = await captureCleanup(cleanupFailure, async () => { if (this.activeChild !== undefined) await terminateChild(this.activeChild); }); + this.activeBrokerTurn?.abort(); cleanupFailure = await captureCleanup(cleanupFailure, () => Promise.all([...this.setupChildren].map((child) => terminateChild(child))).then(() => undefined)); cleanupFailure = await captureCleanup(cleanupFailure, async () => { await this.grokRegistration?.close(); }); cleanupFailure = await captureCleanup(cleanupFailure, async () => { await this.activeMount?.close(); }); diff --git a/src/pi/cliSessionOutput.test.ts b/src/pi/cliSessionOutput.test.ts new file mode 100644 index 0000000..06b0d78 --- /dev/null +++ b/src/pi/cliSessionOutput.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + CLI_ENGINE_MAX_DIAGNOSTIC_BYTES, + CLI_ENGINE_MAX_OUTPUT_BYTES, + createCliSessionFactory, + readChild, + spawnEngine +} from "./cliSession.js"; +import { GrokSubscriptionAuthenticationRejectedError } from "../runtime/grokAuthenticationError.js"; + +test("verbose progress stderr is drained without invalidating a bounded successful reply", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-stderr-progress-")); + const engine = path.join(root, "verbose-success.mjs"); + await writeFile(engine, [ + `process.stderr.write("p".repeat(${CLI_ENGINE_MAX_OUTPUT_BYTES * 8}));`, + 'process.stdout.write("valid assistant reply");' + ].join("\n")); + try { + const child = spawnEngine({ + command: process.execPath, + commandArgs: [engine], + engine: "agy", + maxToolTurns: 1, + timeoutMs: 10_000, + toolAccess: "none" + }, "verbose", { cwd: root }, undefined); + assert.equal(await readChild(child, 10_000, []), "valid assistant reply"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("failed verbose stderr retains only a redacted bounded diagnostic tail", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-stderr-diagnostic-")); + const engine = path.join(root, "verbose-failure.mjs"); + const secret = "bounded-diagnostic-secret-value"; + await writeFile(engine, [ + `process.stderr.write("p".repeat(${CLI_ENGINE_MAX_OUTPUT_BYTES * 8}));`, + `process.stderr.write(${JSON.stringify(` final-error ${secret}`)});`, + "process.exitCode = 7;" + ].join("\n")); + try { + const child = spawnEngine({ + command: process.execPath, + commandArgs: [engine], + engine: "agy", + maxToolTurns: 1, + timeoutMs: 10_000, + toolAccess: "none" + }, "verbose", { cwd: root }, undefined); + await assert.rejects(readChild(child, 10_000, [secret]), (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /CLI engine exited 7/); + assert.match(error.message, /final-error \[REDACTED\]/); + assert.equal(error.message.includes(secret), false); + assert.ok(Buffer.byteLength(error.message) <= CLI_ENGINE_MAX_DIAGNOSTIC_BYTES + 80); + return true; + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("redacts a 2000-byte exact secret before retaining a failed stderr tail", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-long-secret-")); + const engine = path.join(root, "long-secret-failure.mjs"); + const secret = `secret-start-${"q".repeat(1970)}-secret-end`; + await writeFile(engine, [ + `process.stderr.write("p".repeat(${CLI_ENGINE_MAX_OUTPUT_BYTES * 8}));`, + `process.stderr.write(${JSON.stringify(` failure ${secret} terminal-detail`)});`, + "process.exitCode = 9;" + ].join("\n")); + try { + const child = spawnEngine({ + command: process.execPath, commandArgs: [engine], engine: "agy", + maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" + }, "verbose", { cwd: root }, undefined); + await assert.rejects(readChild(child, 10_000, [secret]), (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /terminal-detail/u); + assert.doesNotMatch(error.message, /secret-start|secret-end|q{32}/u); + assert.ok(Buffer.byteLength(error.message) <= CLI_ENGINE_MAX_DIAGNOSTIC_BYTES + 80); + return true; + }); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("Grok replies redact both the staged credential and a credential rotated during the turn", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-output-redaction-")); + const engine = path.join(root, "grok.mjs"); + const oldSecret = "old-access-secret-1234567890"; + const rotatedSecret = "rotated-refresh-secret-0987654321"; + const reply = `provider echoed ${oldSecret} and ${rotatedSecret}`; + await writeFile(engine, `const a=process.argv.slice(2);if(a.includes("mcp"))process.stdout.write("ok");else{const r=${JSON.stringify(reply)},s="session";process.stdout.write(JSON.stringify({type:"assistant",session_id:s,parent_tool_use_id:null,message:{role:"assistant",stop_reason:"end_turn",content:[{type:"text",text:r}]}})+"\\n"+JSON.stringify({type:"result",subtype:"success",is_error:false,stop_reason:"end_turn",errors:[],result:r,session_id:s})+"\\n");}`); + let reads = 0; + try { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: [engine], engine: "grok", + credentialSecretValues: async () => ++reads === 1 ? [oldSecret] : [rotatedSecret] + })({ cwd: root }); + let emitted = ""; + session.subscribe((event) => { + if (event.type === "turn_end") { + const message = event.message as { content: Array<{ text?: string; type: string }> }; + emitted = message.content.map((part) => part.type === "text" ? part.text ?? "" : "").join(""); + } + }); + await session.prompt("work"); + assert.equal(reads, 2); + assert.doesNotMatch(emitted, /old-access|rotated-refresh/u); + assert.match(emitted, /\[REDACTED\]/u); + await session.disposeAsync?.(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("Grok auth rejection is typed and never retains raw credential diagnostics", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-auth-rejection-")); + const engine = path.join(root, "grok.mjs"); + const secret = "access-auth-rejection-secret-123456"; + await writeFile(engine, `const a=process.argv.slice(2);if(a.includes("mcp"))process.stdout.write("ok");else{process.stderr.write("Authentication rejected by server ${secret}");process.exitCode=7;}`); + try { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: [engine], engine: "grok", + credentialSecretValues: async () => [secret] + })({ cwd: root }); + await assert.rejects(session.prompt("work"), (error: unknown) => { + assert.ok(error instanceof GrokSubscriptionAuthenticationRejectedError); + assert.doesNotMatch(error.message, /secret|server/u); + return true; + }); + await session.disposeAsync?.(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("Grok auth rejection is classified before a long secret and verbose tail are truncated", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-streaming-auth-rejection-")); + const engine = path.join(root, "grok.mjs"); + const secret = `access-start-${"s".repeat(1970)}-access-end`; + await writeFile(engine, [ + "const a=process.argv.slice(2);", + "if(a.includes('mcp'))process.stdout.write('ok');", + `else{process.stderr.write("RefreshToken");process.stderr.write("Rejected "+${JSON.stringify(secret)}+" "+"tail".repeat(250));process.exitCode=7;}` + ].join("\n")); + try { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: [engine], engine: "grok", + credentialSecretValues: async () => [secret] + })({ cwd: root }); + await assert.rejects(session.prompt("work"), (error: unknown) => { + assert.ok(error instanceof GrokSubscriptionAuthenticationRejectedError); + assert.doesNotMatch(error.message, /RefreshToken|access-start|access-end|s{32}|tail/u); + return true; + }); + await session.disposeAsync?.(); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/pi/cliSessionProcess.test.ts b/src/pi/cliSessionProcess.test.ts index ffae48a..095d5ce 100644 --- a/src/pi/cliSessionProcess.test.ts +++ b/src/pi/cliSessionProcess.test.ts @@ -6,6 +6,11 @@ import test from "node:test"; import { createCliSessionFactory, readChild, spawnEngine, terminateChild } from "./cliSession.js"; +const grokStream = (text: string): string => [ + { type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text }] } }, + { type: "result", subtype: "success", is_error: false, result: text, stop_reason: "end_turn", session_id: "fake" } +].map((event) => JSON.stringify(event)).join("\n"); + test("terminates a process group after its leader has exited", async (context) => { if (!requirePosixProcessGroups(context)) return; const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-group-")); @@ -36,7 +41,7 @@ test("Grok setup reaps a stubborn descendant after its successful leader exits", child.stdout.once("data", () => { writeFileSync(${JSON.stringify(descendant)}, String(child.pid)); process.exit(0); }); } if (args.includes("remove")) process.exit(0); - process.stdout.write("engine complete");`); + process.stdout.write(${JSON.stringify(grokStream("engine complete"))});`); try { const { session } = await createCliSessionFactory({ command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 @@ -61,7 +66,7 @@ test("Grok removal reaps a stubborn descendant after its successful leader exits if (args.includes("remove")) { const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => undefined); process.stdout.write('ready'); setInterval(() => undefined, 1000)"], { stdio: ["ignore", "pipe", "ignore"] }); child.stdout.once("data", () => { writeFileSync(${JSON.stringify(descendant)}, String(child.pid)); process.exit(0); }); - } else if (args.includes("add")) process.exit(0); else process.stdout.write("engine complete");`); + } else if (args.includes("add")) process.exit(0); else process.stdout.write(${JSON.stringify(grokStream("engine complete"))});`); try { const { session } = await createCliSessionFactory({ command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 diff --git a/src/pi/cliSessionRemoval.test.ts b/src/pi/cliSessionRemoval.test.ts index 886c5b1..aa880ac 100644 --- a/src/pi/cliSessionRemoval.test.ts +++ b/src/pi/cliSessionRemoval.test.ts @@ -5,11 +5,17 @@ import path from "node:path"; import test from "node:test"; import { createCliSessionFactory } from "./cliSession.js"; +import { GrokSubscriptionAuthenticationRejectedError } from "../runtime/grokAuthenticationError.js"; + +const grokStream = (text: string): string => [ + { type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text }] } }, + { type: "result", subtype: "success", is_error: false, result: text, stop_reason: "end_turn", session_id: "fake" } +].map((event) => JSON.stringify(event)).join("\n"); test("Grok removal failure rejects without emitting a successful turn", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-remove-failure-")); const grok = path.join(root, "grok.mjs"); - await writeFile(grok, `const args = process.argv.slice(2); if (args.includes("remove")) process.exit(23); else if (args.includes("add")) process.exit(0); else process.stdout.write("engine complete");`); + await writeFile(grok, `const args = process.argv.slice(2); if (args.includes("remove")) process.exit(23); else if (args.includes("add")) process.exit(0); else process.stdout.write(${JSON.stringify(grokStream("engine complete"))});`); try { const { session } = await createCliSessionFactory({ command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 })({ cwd: root }); let turns = 0; @@ -22,3 +28,44 @@ test("Grok removal failure rejects without emitting a successful turn", async () await rm(root, { recursive: true, force: true }); } }); + +test("Grok authentication rejection keeps precedence over bounded removal failure", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-auth-remove-failure-")); + const grok = path.join(root, "grok.mjs"); + const authSecret = "auth-rejection-secret-canary"; + const cleanupSecret = "cleanup-secret-canary"; + await writeFile(grok, `const args=process.argv.slice(2);if(args.includes("remove")){process.stderr.write("cleanup ${cleanupSecret}");process.exit(23)}else if(args.includes("add"))process.exit(0);else{process.stderr.write("RefreshTokenRejected ${authSecret}");process.exit(7)}`); + try { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, + timeoutMs: 10_000, credentialSecretValues: async () => [authSecret, cleanupSecret] + })({ cwd: root }); + await assert.rejects(session.prompt("research"), (error: unknown) => { + assert.ok(error instanceof GrokSubscriptionAuthenticationRejectedError); + assert.doesNotMatch(error.message, /canary|cleanup|RefreshTokenRejected/u); + assert.ok(error.cause instanceof Error); + assert.match(error.cause.message, /CLI engine exited 23/u); + assert.doesNotMatch(error.cause.message, /canary|cleanup-secret/u); + assert.ok(Buffer.byteLength(error.cause.message) < 1_024); + return true; + }); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("Grok removal auth rejection is typed before verbose diagnostics are truncated", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-remove-auth-rejection-")); + const grok = path.join(root, "grok.mjs"); + const secret = `refresh-start-${"r".repeat(1970)}-refresh-end`; + await writeFile(grok, `const a=process.argv.slice(2);if(a.includes("remove")){process.stderr.write("RefreshTokenRejected "+${JSON.stringify(secret)}+" "+"tail".repeat(250));process.exit(23)}else if(a.includes("add"))process.exit(0);else process.stdout.write(${JSON.stringify(grokStream("complete"))});`); + try { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: [grok], engine: "grok", + credentialSecretValues: async () => [secret] + })({ cwd: root }); + await assert.rejects(session.prompt("research"), (error: unknown) => { + assert.ok(error instanceof GrokSubscriptionAuthenticationRejectedError); + assert.doesNotMatch(error.message, /RefreshToken|refresh-start|refresh-end|r{32}|tail/u); + return true; + }); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/pi/grokHeadlessResult.test.ts b/src/pi/grokHeadlessResult.test.ts new file mode 100644 index 0000000..01d309b --- /dev/null +++ b/src/pi/grokHeadlessResult.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { CLI_ENGINE_MAX_OUTPUT_BYTES, createCliSessionFactory } from "./cliSession.js"; +import { decodeGrokHeadlessResult } from "./grokHeadlessResult.js"; + +const assistant = (text: string, stopReason: string = "end_turn", sessionId: string = "session-1") => ({ + type: "assistant", parent_tool_use_id: null, session_id: sessionId, + message: { role: "assistant", stop_reason: stopReason, content: [{ type: "text", text }] } +}); + +const result = (text: string, stopReason: string = "end_turn", sessionId: string = "session-1") => ({ + type: "result", subtype: "success", is_error: false, result: text, stop_reason: stopReason, session_id: sessionId +}); + +const stream = (...events: readonly unknown[]): string => events.map((event) => JSON.stringify(event)).join("\n"); + +test("Grok message stream ignores multiple tool narrations and returns the exact matching terminal answer", () => { + assert.equal(decodeGrokHeadlessResult(stream( + { type: "system", subtype: "init", session_id: "session-1" }, + { ...assistant("I'll inspect the room.", "tool_use"), message: { + role: "assistant", stop_reason: "tool_use", content: [ + { type: "text", text: "I'll inspect the room." }, { type: "tool_use", id: "tool-1", name: "read_file", input: { path: "one" } } + ] + } }, + { type: "user", parent_tool_use_id: null, session_id: "session-1", message: { role: "user", content: [{ + type: "tool_result", tool_use_id: "tool-1", content: "x".repeat(CLI_ENGINE_MAX_OUTPUT_BYTES * 2) + }] } }, + { ...assistant("I need one more source.", "tool_use"), message: { + role: "assistant", stop_reason: "tool_use", content: [ + { type: "text", text: "I need one more source." }, { type: "tool_use", id: "tool-2", name: "read_file", input: { path: "two" } } + ] + } }, + { type: "user", parent_tool_use_id: null, session_id: "session-1", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "tool-2", content: "two" }] } }, + { ...assistant("publishable ACK"), message: { role: "assistant", stop_reason: "end_turn", content: [ + { type: "thinking", thinking: "compose" }, { type: "text", text: "publishable ACK" } + ] } }, + result("publishable ACK") + )), "publishable ACK"); + assert.equal(decodeGrokHeadlessResult(stream(assistant("bounded final", "stop_sequence"), result("bounded final", "stop_sequence"))), "bounded final"); +}); + +test("Grok message stream rejects cancelled, error, malformed, empty, and mismatched terminals without leaking text", () => { + const canary = "progress-must-not-publish"; + for (const output of [ + stream(assistant(canary, "cancelled"), result(canary, "cancelled")), + stream({ type: "error", message: canary }), + stream(result(canary)), + stream(assistant(canary), result("different")), + stream(assistant(" "), result(" ")), + stream(assistant(canary), { ...result(canary), errors: [canary] }), + stream(assistant(canary), { ...result(canary), errors: canary }), + stream(result(canary), assistant(canary)), + stream({ ...assistant(canary), message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "tool_use", id: "tool-1" }] } }, result(canary)), + canary + ]) { + assert.throws(() => decodeGrokHeadlessResult(output), (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /no publishable terminal response/); + assert.equal(error.message.includes(canary), false); + return true; + }); + } +}); + +test("exit-zero cancelled Grok sessions reject without emitting a turn", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-cancelled-result-")); + const grok = path.join(root, "grok.mjs"); + const cancelled = stream(assistant("progress-must-not-publish", "cancelled"), result("progress-must-not-publish", "cancelled")); + await writeFile(grok, [ + "const args = process.argv.slice(2);", + "if (args.includes('mcp')) process.stdout.write('ok');", + `else process.stdout.write(${JSON.stringify(cancelled)});` + ].join("\n")); + try { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: [grok], engine: "grok", timeoutMs: 10_000 + })({ cwd: root }); + let turns = 0; + session.subscribe((event) => { if (event.type === "turn_end") turns += 1; }); + await assert.rejects(session.prompt("research"), /no publishable terminal response/); + assert.equal(turns, 0); + await session.disposeAsync?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("successful Grok sessions emit only decoded terminal text", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-terminal-result-")); + const grok = path.join(root, "grok.mjs"); + const multiTool = stream( + assistant("first progress note", "tool_use"), + { type: "user", parent_tool_use_id: null, session_id: "session-1", message: { role: "user", content: [{ + type: "tool_result", tool_use_id: "tool-1", content: "x".repeat(CLI_ENGINE_MAX_OUTPUT_BYTES * 2) + }] } }, + assistant("second progress note", "tool_use"), + { type: "user", parent_tool_use_id: null, session_id: "session-1", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "tool-2", content: "two" }] } }, + assistant("publishable ACK"), result("publishable ACK") + ); + await writeFile(grok, [ + "const args = process.argv.slice(2);", + "if (args.includes('mcp')) process.stdout.write('ok');", + `else process.stdout.write(${JSON.stringify(multiTool)});` + ].join("\n")); + try { + const { session } = await createCliSessionFactory({ + command: process.execPath, commandArgs: [grok], engine: "grok", timeoutMs: 10_000 + })({ cwd: root }); + let reply = ""; + session.subscribe((event) => { + if (event.type !== "turn_end" || !("content" in event.message) || !Array.isArray(event.message.content)) return; + reply = event.message.content.filter((entry) => entry.type === "text").map((entry) => entry.text).join(""); + }); + await session.prompt("research"); + assert.equal(reply, "publishable ACK"); + await session.disposeAsync?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/pi/grokHeadlessResult.ts b/src/pi/grokHeadlessResult.ts new file mode 100644 index 0000000..33c5363 --- /dev/null +++ b/src/pi/grokHeadlessResult.ts @@ -0,0 +1,66 @@ +const PUBLISHABLE_STOP_REASONS = new Set(["end_turn", "stop_sequence"]); + +type JsonRecord = Readonly>; + +const isRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value); + +const invalidResult = (detail: string): Error => + new Error(`Grok CLI returned no publishable terminal response: ${detail}`); + +type TerminalAssistant = Readonly<{ sessionId: string; stopReason: string; text: string }>; + +const decodeTerminalAssistant = (event: JsonRecord): TerminalAssistant | undefined => { + if (event.type !== "assistant" || event.parent_tool_use_id !== null) return undefined; + if (typeof event.session_id !== "string" || !isRecord(event.message)) throw invalidResult("invalid assistant event"); + const message = event.message; + if (message.role !== "assistant" || typeof message.stop_reason !== "string" || !Array.isArray(message.content)) { + throw invalidResult("invalid assistant event"); + } + if (!PUBLISHABLE_STOP_REASONS.has(message.stop_reason)) return { sessionId: event.session_id, stopReason: message.stop_reason, text: "" }; + let text = ""; + let textBlocks = 0; + for (const block of message.content) { + if (!isRecord(block) || typeof block.type !== "string") throw invalidResult("invalid assistant content"); + if (block.type === "thinking") continue; + if (block.type !== "text" || typeof block.text !== "string") throw invalidResult("invalid terminal content"); + text += block.text; + textBlocks += 1; + } + if (textBlocks === 0 || text.trim().length === 0) throw invalidResult("empty response"); + return { sessionId: event.session_id, stopReason: message.stop_reason, text }; +}; + +/** Decode Grok's terminal message stream without treating progress messages as a reply. */ +export const decodeGrokHeadlessResult = (output: string): string => { + const lines = output.split(/\r?\n/).filter((line) => line.length > 0); + if (lines.length === 0) throw invalidResult("empty stream"); + let finalAssistant: TerminalAssistant | undefined; + let result: JsonRecord | undefined; + for (const [index, line] of lines.entries()) { + let event: unknown; + try { event = JSON.parse(line); } catch { throw invalidResult("invalid JSON"); } + if (!isRecord(event) || typeof event.type !== "string") throw invalidResult("invalid event"); + if (event.type === "error") { + const rejection = asGrokAuthenticationRejected(JSON.stringify(event)); + if (rejection !== undefined) throw rejection; + throw invalidResult("engine error"); + } + const assistant = decodeTerminalAssistant(event); + if (assistant !== undefined) finalAssistant = assistant; + if (event.type === "result") { + if (index !== lines.length - 1) throw invalidResult("non-terminal result"); + result = event; + } + } + if (result === undefined || result.subtype !== "success" || result.is_error !== false) throw invalidResult("unsuccessful result"); + if (typeof result.stop_reason !== "string" || !PUBLISHABLE_STOP_REASONS.has(result.stop_reason)) throw invalidResult("non-terminal stop"); + if ("errors" in result && (!Array.isArray(result.errors) || result.errors.length > 0)) throw invalidResult("engine error"); + if (typeof result.result !== "string" || result.result.trim().length === 0) throw invalidResult("empty response"); + if (finalAssistant === undefined || finalAssistant.stopReason !== result.stop_reason || + finalAssistant.sessionId !== result.session_id || finalAssistant.text !== result.result) { + throw invalidResult("terminal result mismatch"); + } + return result.result.trim(); +}; +import { asGrokAuthenticationRejected } from "../runtime/grokAuthenticationError.js"; diff --git a/src/pi/grokSandbox.test.ts b/src/pi/grokSandbox.test.ts new file mode 100644 index 0000000..d8bd5d0 --- /dev/null +++ b/src/pi/grokSandbox.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + GROK_DAIMON_SANDBOX_PROFILE, + prepareAndVerifyGrokSandbox +} from "./grokSandbox.js"; + +test("requires an enforced custom profile containing every protected peer and credential root", async () => { + const fixture = await createFixture(); + try { + await prepareAndVerifyGrokSandbox(fixture.authority); + const profile = await readFile(path.join(fixture.engineHomePath, "sandbox.toml"), "utf8"); + assert.match(profile, new RegExp(`profiles\\.${GROK_DAIMON_SANDBOX_PROFILE}`, "u")); + for (const protectedPath of fixture.protectedPaths) assert.match(profile, new RegExp(escape(protectedPath), "u")); + assert.doesNotMatch(profile, /credential-canary/u); + } finally { await rm(fixture.root, { force: true, recursive: true }); } +}); + +test("fails closed when Grok reports a fail-open sandbox or drops one deny path", async () => { + for (const mode of ["unenforced", "drop-deny"] as const) { + const fixture = await createFixture(mode); + try { + await assert.rejects( + prepareAndVerifyGrokSandbox(fixture.authority), + /kernel sandbox enforcement is unavailable/u + ); + } finally { await rm(fixture.root, { force: true, recursive: true }); } + } +}); + +test("rejects a protected root overlapping the selected agent workspace", async () => { + const fixture = await createFixture(); + try { + await assert.rejects(prepareAndVerifyGrokSandbox({ + ...fixture.authority, + protectedPaths: [...fixture.protectedPaths, fixture.cwd] + }), /kernel sandbox enforcement is unavailable/u); + } finally { await rm(fixture.root, { force: true, recursive: true }); } +}); + +test("rotates its private enforcement receipt before the bounded log is exhausted", async () => { + const fixture = await createFixture(); + try { + const events = path.join(fixture.engineHomePath, "sandbox-events.jsonl"); + await writeFile(events, "x".repeat(8 * 1024 * 1024), { mode: 0o600 }); + await prepareAndVerifyGrokSandbox(fixture.authority); + assert.ok((await readFile(events)).byteLength < 64 * 1024); + } finally { await rm(fixture.root, { force: true, recursive: true }); } +}); + +async function createFixture(mode: "valid" | "unenforced" | "drop-deny" = "valid"): Promise<{ + authority: Parameters[0]; + cwd: string; + engineHomePath: string; + protectedPaths: string[]; + root: string; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-sandbox-")); + const runtimeHomePath = path.join(root, "runtime-home"); + const engineHomePath = path.join(runtimeHomePath, ".grok"); + const cwd = path.join(root, "workspace"); + const protectedPaths = [path.join(root, "credential-realm"), path.join(root, "peer-home")]; + await Promise.all([runtimeHomePath, engineHomePath, cwd, ...protectedPaths].map((directory) => + mkdir(directory, { mode: 0o700, recursive: true }) + )); + await writeFile(path.join(protectedPaths[0]!, "auth.json"), "credential-canary", { mode: 0o600 }); + const command = path.join(root, "grok"); + await writeFile(command, `#!/usr/bin/env node +const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),home=process.env.GROK_HOME; +const profile=fs.readFileSync(path.join(home,"sandbox.toml"),"utf8"); +const deny=JSON.parse(profile.split("\\n").find((line)=>line.startsWith("deny = ")).slice(7)); +const observed=${JSON.stringify(mode)}==="drop-deny"?deny.slice(1):deny; +const event={event_type:"ProfileApplied",profile:"${GROK_DAIMON_SANDBOX_PROFILE}",workspace:fs.realpathSync(args[args.indexOf("--cwd")+1]),platform:"linux/landlock",enforced:${JSON.stringify(mode)}!=="unenforced",restrict_network:true,deny_paths:observed}; +fs.appendFileSync(path.join(home,"sandbox-events.jsonl"),JSON.stringify(event)+"\\n",{mode:0o600}); +fs.chmodSync(path.join(home,"sandbox-events.jsonl"),0o600); +`); + await chmod(command, 0o700); + return { + authority: { command, cwd, engineHomePath, protectedPaths, runtimeHomePath }, + cwd, + engineHomePath, + protectedPaths, + root + }; +} + +const escape = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); diff --git a/src/pi/grokSandbox.ts b/src/pi/grokSandbox.ts new file mode 100644 index 0000000..956095b --- /dev/null +++ b/src/pi/grokSandbox.ts @@ -0,0 +1,210 @@ +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { constants } from "node:fs"; +import { lstat, open, realpath, rename, unlink } from "node:fs/promises"; +import path from "node:path"; + +import { readChild } from "./cliChildOutput.js"; +import { cliChildEnvironment } from "./cliEnvironment.js"; +import { terminateChild, trackCliChild } from "./cliProcess.js"; +import { renderGrokSandboxArgs } from "./cliEngineSpawn.js"; + +export const GROK_DAIMON_SANDBOX_PROFILE = "daimon-strict"; +const SANDBOX_CONFIG = "sandbox.toml"; +const SANDBOX_EVENTS = "sandbox-events.jsonl"; +const MAX_EVENTS_BYTES = 16 * 1024 * 1024; +const ROTATE_EVENTS_BYTES = 8 * 1024 * 1024; + +export interface GrokSandboxAuthority { + readonly command: string; + readonly commandArgs?: readonly string[]; + readonly cwd: string; + readonly engineHomePath: string; + readonly protectedPaths: readonly string[]; + readonly runtimeHomePath: string; +} + +export async function prepareAndVerifyGrokSandbox( + authority: GrokSandboxAuthority +): Promise { + const engineHome = await realpath(authority.engineHomePath); + const cwd = await realpath(authority.cwd); + await assertPrivateDirectory(engineHome); + const denied = [...new Set(await Promise.all(authority.protectedPaths.map((entry) => realpath(entry))))].sort(); + if (denied.some((entry) => overlaps(entry, cwd) || overlaps(entry, engineHome))) { + throw unavailable(); + } + await writeProfile(engineHome, denied); + const beforeBytes = await eventFileSize(path.join(engineHome, SANDBOX_EVENTS)); + const child = trackCliChild(spawn(authority.command, [ + ...renderGrokSandboxArgs(authority.commandArgs, GROK_DAIMON_SANDBOX_PROFILE), + "--cwd", cwd, + "inspect" + ], { + cwd, + detached: process.platform !== "win32", + env: cliChildEnvironment([], authority.runtimeHomePath, { + engine: "grok", + engineHomePath: engineHome, + executablePath: authority.command + }), + stdio: ["ignore", "pipe", "pipe"] + })); + try { + await readChild(child, 10_000, []); + } catch { + throw unavailable(); + } finally { + await terminateChild(child).catch(() => undefined); + } + await verifyEnforcementEvent(path.join(engineHome, SANDBOX_EVENTS), beforeBytes, cwd, denied); + await verifyProfile(engineHome, denied); +} + +const profileText = (denied: readonly string[]): string => [ + `[profiles.${GROK_DAIMON_SANDBOX_PROFILE}]`, + 'extends = "strict"', + "restrict_network = true", + `deny = [${denied.map((entry) => JSON.stringify(entry)).join(", ")}]`, + "" +].join("\n"); + +async function writeProfile(engineHome: string, denied: readonly string[]): Promise { + const target = path.join(engineHome, SANDBOX_CONFIG); + const temporary = path.join(engineHome, `.${SANDBOX_CONFIG}.${randomUUID()}.tmp`); + let handle: Awaited> | undefined; + try { + handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow(), 0o600); + await handle.writeFile(profileText(denied)); + await handle.chmod(0o600); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, target); + await syncDirectory(engineHome); + await verifyProfile(engineHome, denied); + } catch { + throw unavailable(); + } finally { + await handle?.close().catch(() => undefined); + await unlink(temporary).catch(() => undefined); + } +} + +async function verifyProfile(engineHome: string, denied: readonly string[]): Promise { + const file = path.join(engineHome, SANDBOX_CONFIG); + let handle: Awaited> | undefined; + try { + const before = await lstat(file); + assertPrivateFile(before, 64 * 1024); + handle = await open(file, constants.O_RDONLY | noFollow()); + const opened = await handle.stat(); + assertPrivateFile(opened, 64 * 1024); + if (!sameIdentity(before, opened) || await handle.readFile("utf8") !== profileText(denied)) throw new Error("changed"); + const after = await handle.stat(); + if (!sameIdentity(opened, after)) throw new Error("changed"); + } catch { + throw unavailable(); + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function verifyEnforcementEvent( + file: string, + beforeBytes: number, + cwd: string, + denied: readonly string[] +): Promise { + let handle: Awaited> | undefined; + try { + const before = await lstat(file); + assertPrivateFile(before, MAX_EVENTS_BYTES); + if (before.size <= beforeBytes) throw new Error("missing event"); + handle = await open(file, constants.O_RDONLY | noFollow()); + const opened = await handle.stat(); + if (!sameIdentity(before, opened)) throw new Error("changed"); + const bytes = await handle.readFile(); + const after = await handle.stat(); + if (!sameIdentity(opened, after)) throw new Error("changed"); + const appended = bytes.subarray(beforeBytes).toString("utf8").trim().split("\n").filter(Boolean); + const event = JSON.parse(appended.at(-1) ?? "null") as Record | null; + const observedDenied = Array.isArray(event?.deny_paths) + ? event.deny_paths.filter((entry): entry is string => typeof entry === "string").sort() + : []; + if (event?.event_type !== "ProfileApplied" || event.profile !== GROK_DAIMON_SANDBOX_PROFILE + || event.enforced !== true || event.restrict_network !== true || event.workspace !== cwd + || !/^(?:linux\/landlock|macos\/seatbelt)$/u.test(String(event.platform ?? "")) + || JSON.stringify(observedDenied) !== JSON.stringify(denied)) throw new Error("not enforced"); + bytes.fill(0); + } catch { + throw unavailable(); + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function eventFileSize(file: string): Promise { + try { + const entry = await lstat(file); + assertPrivateFile(entry, MAX_EVENTS_BYTES, true); + if (entry.size >= ROTATE_EVENTS_BYTES) { + await replaceWithEmptyPrivateFile(file); + return 0; + } + return Number(entry.size); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + const handle = await open(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow(), 0o600); + try { await handle.sync(); } finally { await handle.close(); } + await syncDirectory(path.dirname(file)); + return 0; + } + throw unavailable(); + } +} + +async function replaceWithEmptyPrivateFile(file: string): Promise { + const temporary = `${file}.${randomUUID()}.tmp`; + let handle: Awaited> | undefined; + try { + handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow(), 0o600); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, file); + await syncDirectory(path.dirname(file)); + const entry = await lstat(file); + assertPrivateFile(entry, 0, true); + } catch { + throw unavailable(); + } finally { + await handle?.close().catch(() => undefined); + await unlink(temporary).catch(() => undefined); + } +} + +async function assertPrivateDirectory(directory: string): Promise { + try { + const entry = await lstat(directory); + if (!entry.isDirectory() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() + || (Number(entry.mode) & 0o777) !== 0o700) throw new Error("unsafe"); + } catch { throw unavailable(); } +} + +async function syncDirectory(directory: string): Promise { + const handle = await open(directory, constants.O_RDONLY); + try { await handle.sync(); } finally { await handle.close(); } +} +const overlaps = (left: string, right: string): boolean => + left === right || left.startsWith(`${right}${path.sep}`) || right.startsWith(`${left}${path.sep}`); +const sameIdentity = (left: Awaited>, right: Awaited>): boolean => + left.dev === right.dev && left.ino === right.ino && left.mode === right.mode + && left.nlink === right.nlink && left.size === right.size && left.uid === right.uid && left.mtimeMs === right.mtimeMs; +const assertPrivateFile = (entry: Awaited>, maxBytes: number, allowEmpty = false): void => { + if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() + || entry.nlink !== 1 || (Number(entry.mode) & 0o777) !== 0o600 + || (!allowEmpty && entry.size < 1) || entry.size > maxBytes) throw new Error("unsafe"); +}; +const noFollow = (): number => (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; +const unavailable = (): Error => new Error("Grok kernel sandbox enforcement is unavailable"); diff --git a/src/pi/turnTrace.ts b/src/pi/turnTrace.ts index 827f0f6..0f0f45c 100644 --- a/src/pi/turnTrace.ts +++ b/src/pi/turnTrace.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { MemoryPrepareTurnResult, MemoryWakeMode } from "@noopolis/mneme"; import type { HarnessModelSpec, WakeEvent } from "../core/types.js"; +import { redactCredentialText } from "../core/credentialRedaction.js"; export interface PiTurnTraceModel { authMethod: NonNullable["method"]; @@ -131,14 +132,7 @@ export interface PersistPiTurnTraceInput extends Omit { - let redacted = String(value); - redacted = redacted.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/giu, "Bearer [REDACTED]"); - redacted = redacted.replace(/\bmagt_v1_[A-Za-z0-9_-]{16,}\b/gu, "[REDACTED]"); - redacted = redacted.replace(/\b(?:sk|sk-proj)-[A-Za-z0-9_-]{20,}\b/gu, "[REDACTED]"); - redacted = redacted.replace( - /("([^"]*(?:api[_-]?key|token|secret|password)[^"]*)"\s*:\s*")([^"]+)(")/giu, - "$1[REDACTED]$4" - ); + let redacted = redactCredentialText(value, [], 4_096); redacted = redacted.replace(/\/(?:Users|home|private|tmp|var|opt|run)\/[^\s"']+/gu, "[path]"); return redacted.length > 1000 ? `${redacted.slice(0, 1000)}...` : redacted; }; diff --git a/src/runtime/grokAuthenticationError.ts b/src/runtime/grokAuthenticationError.ts new file mode 100644 index 0000000..cc0e061 --- /dev/null +++ b/src/runtime/grokAuthenticationError.ts @@ -0,0 +1,27 @@ +const AUTH_REJECTION = /(?:Authentication rejected by server|Auth recovery exhausted|RefreshTokenRejected|Not signed in|Not logged in|NoRefresh|NoRecovery|PinnedTeamMismatch|ServerRejected)/iu; + +/** Classifies a bounded streaming diagnostic without retaining provider output. */ +export function classifyGrokAuthenticationDiagnostic( + diagnostic: string +): GrokSubscriptionAuthenticationRejectedError | undefined { + return AUTH_REJECTION.test(diagnostic) + ? new GrokSubscriptionAuthenticationRejectedError() + : undefined; +} + +/** Fixed-message marker used to stale-fence a rotating Grok credential realm. */ +export class GrokSubscriptionAuthenticationRejectedError extends Error { + public constructor(cleanupFailure?: Error) { + super( + "Grok subscription authentication was rejected; operator re-enrollment is required", + cleanupFailure === undefined ? undefined : { cause: cleanupFailure } + ); + this.name = "GrokSubscriptionAuthenticationRejectedError"; + } +} + +export function asGrokAuthenticationRejected(error: unknown): GrokSubscriptionAuthenticationRejectedError | undefined { + if (error instanceof GrokSubscriptionAuthenticationRejectedError) return error; + const message = error instanceof Error ? error.message : String(error); + return classifyGrokAuthenticationDiagnostic(message); +} diff --git a/src/runtime/grokSubscriptionRealm.test.ts b/src/runtime/grokSubscriptionRealm.test.ts new file mode 100644 index 0000000..f1e1ece --- /dev/null +++ b/src/runtime/grokSubscriptionRealm.test.ts @@ -0,0 +1,362 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmod, link, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { startGrokSubscriptionRealm } from "./grokSubscriptionRealm.js"; +import { GrokSubscriptionAuthenticationRejectedError } from "./grokAuthenticationError.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +type GrokAgent = OrganizationRuntimeAgentConfig & { engine: { kind: "grok" } }; +const credential = (generation: string): string => JSON.stringify({ + "https://auth.x.ai::account": { + expires_at: "2099-01-01T00:00:00.000Z", + key: `access-${generation}`, + refresh_token: `refresh-${generation}` + } +}); +const agent = (root: string, id: string): GrokAgent => ({ + engine: { kind: "grok" }, id, instructions: "Work.", name: id, + runtimeHomePath: path.join(root, "homes", id), workspacePath: path.join(root, "workspaces", id) +}); + +test("serializes Grok turns, promotes rotation, and preserves the realm across restart", async () => { + const fixture = await createFixture(); + const first = agent(fixture.root, "first"); + const second = agent(fixture.root, "second"); + await prepareAgents([first, second]); + let realm = await startGrokSubscriptionRealm([first, second], fixture.options); + const events: string[] = []; + let release!: () => void; + const blocked = new Promise((resolve) => { release = resolve; }); + try { + const firstTurn = realm.withCredential(first, async () => { + events.push("first-start"); + assert.equal(await readFile(authPath(first), "utf8"), credential("bootstrap")); + await blocked; + await writePrivate(authPath(first), credential("rotated-one")); + events.push("first-end"); + }); + const secondTurn = realm.withCredential(second, async () => { + events.push("second-start"); + assert.equal(await readFile(authPath(second), "utf8"), credential("rotated-one")); + await writePrivate(authPath(second), credential("rotated-two")); + events.push("second-end"); + }); + for (let attempt = 0; events.length === 0 && attempt < 100; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + try { assert.deepEqual(events, ["first-start"]); } finally { release(); } + await Promise.all([firstTurn, secondTurn]); + assert.deepEqual(events, ["first-start", "first-end", "second-start", "second-end"]); + await assert.rejects(readFile(authPath(first)), { code: "ENOENT" }); + await assert.rejects(readFile(authPath(second)), { code: "ENOENT" }); + await realm.close(); + + realm = await startGrokSubscriptionRealm([first, second], fixture.options); + await realm.withCredential(first, async () => { + assert.equal(await readFile(authPath(first), "utf8"), credential("rotated-two")); + }); + } finally { + await realm.close().catch(() => undefined); + await fixture.close(); + } +}); + +test("fails stale after credential deletion and accepts only a fresh operator bootstrap", async () => { + const fixture = await createFixture(); + const configured = agent(fixture.root, "reporter"); + await prepareAgents([configured]); + let realm = await startGrokSubscriptionRealm([configured], fixture.options); + try { + await assert.rejects(realm.withCredential(configured, async () => { + await rm(authPath(configured)); + throw new Error("RefreshTokenRejected secret-canary"); + }), (error: Error) => { + assert.equal(error.message, "Grok subscription credential realm is unavailable or stale"); + assert.doesNotMatch(error.message, /secret-canary|RefreshTokenRejected|reporter/u); + return true; + }); + await assert.rejects(realm.withCredential(configured, async () => undefined), /unavailable or stale/); + await realm.close(); + + await writePrivate(fixture.bootstrapPath, credential("operator-renewed")); + const future = new Date(Date.now() + 2_000); + await utimes(fixture.bootstrapPath, future, future); + realm = await startGrokSubscriptionRealm([configured], fixture.options); + await realm.withCredential(configured, async () => { + assert.equal(await readFile(authPath(configured), "utf8"), credential("operator-renewed")); + }); + } finally { + await realm.close().catch(() => undefined); + await fixture.close(); + } +}); + +test("never reimports an unchanged revoked bootstrap when durable authority is absent", async () => { + const fixture = await createFixture(); + const configured = agent(fixture.root, "revoked-bootstrap"); + await prepareAgents([configured]); + const realm = await startGrokSubscriptionRealm([configured], fixture.options); + try { + await assert.rejects(realm.withCredential(configured, async () => { + throw new GrokSubscriptionAuthenticationRejectedError(); + }), /unavailable or stale/u); + await realm.close(); + await rm(path.join(fixture.durablePath, "auth.json")); + await assert.rejects(startGrokSubscriptionRealm([configured], fixture.options), /unavailable or stale/u); + await assert.rejects(readFile(path.join(fixture.durablePath, "auth.json")), { code: "ENOENT" }); + } finally { await realm.close().catch(() => undefined); await fixture.close(); } +}); + +test("stale-fences a typed provider auth rejection even when Grok leaves valid stale bytes", async () => { + const fixture = await createFixture(); + const configured = agent(fixture.root, "reviewer"); + await prepareAgents([configured]); + const realm = await startGrokSubscriptionRealm([configured], fixture.options); + try { + await assert.rejects(realm.withCredential(configured, async () => { + await writePrivate(authPath(configured), credential("still-structurally-valid")); + throw new GrokSubscriptionAuthenticationRejectedError(); + }), (error: Error) => { + assert.equal(error.message, "Grok subscription credential realm is unavailable or stale"); + assert.doesNotMatch(error.message, /reviewer|structurally|rejected/u); + return true; + }); + await assert.rejects(realm.withCredential(configured, async () => undefined), /unavailable or stale/u); + } finally { await realm.close(); await fixture.close(); } +}); + +test("recovers the journaled rotated credential after a host crash", async () => { + const fixture = await createFixture(); + const configured = agent(fixture.root, "editor"); + await prepareAgents([configured]); + const sourceDigest = createHash("sha256").update(credential("before-crash")).digest("hex"); + await writePrivate(path.join(fixture.durablePath, "auth.json"), credential("before-crash")); + await writePrivate(authPath(configured), credential("after-crash")); + await writePrivate(path.join(fixture.durablePath, "lease.json"), `${JSON.stringify({ + agent_id: configured.id, source_digest: sourceDigest, state: "active", + version: "noopolis.daimon.grok-credential-lease.v1" + })}\n`); + const realm = await startGrokSubscriptionRealm([configured], fixture.options); + try { + await realm.withCredential(configured, async () => { + assert.equal(await readFile(authPath(configured), "utf8"), credential("after-crash")); + }); + } finally { + await realm.close(); await fixture.close(); + } +}); + +test("stale-fences a crash journal whose source authority changed", async () => { + const fixture = await createFixture(); + const configured = agent(fixture.root, "source-drift"); + await prepareAgents([configured]); + await writePrivate(path.join(fixture.durablePath, "auth.json"), credential("unexpected-source")); + await writePrivate(authPath(configured), credential("candidate")); + await writePrivate(path.join(fixture.durablePath, "lease.json"), `${JSON.stringify({ + agent_id: configured.id, + source_digest: createHash("sha256").update(credential("expected-source")).digest("hex"), + state: "active", + version: "noopolis.daimon.grok-credential-lease.v1" + })}\n`); + try { + await assert.rejects(startGrokSubscriptionRealm([configured], fixture.options), /unavailable or stale/u); + await assert.rejects(readFile(authPath(configured)), { code: "ENOENT" }); + } finally { await fixture.close(); } +}); + +test("recovers every post-promotion crash cut without falsely staling the realm", async () => { + for (const stagedCopyPresent of [true, false]) { + const fixture = await createFixture(); + const configured = agent(fixture.root, stagedCopyPresent ? "promoted-copy" : "promoted-cleaned"); + await prepareAgents([configured]); + const promoted = credential(`promoted-${stagedCopyPresent}`); + const promotedDigest = createHash("sha256").update(promoted).digest("hex"); + await writePrivate(path.join(fixture.durablePath, "auth.json"), promoted); + if (stagedCopyPresent) await writePrivate(authPath(configured), promoted); + await writePrivate(path.join(fixture.durablePath, "lease.json"), `${JSON.stringify({ + agent_id: configured.id, promoted_digest: promotedDigest, source_digest: "1".repeat(64), state: "promoted", + version: "noopolis.daimon.grok-credential-lease.v1" + })}\n`); + const realm = await startGrokSubscriptionRealm([configured], fixture.options); + try { + await realm.withCredential(configured, async () => { + assert.equal(await readFile(authPath(configured), "utf8"), promoted); + }); + await assert.rejects(readFile(path.join(fixture.durablePath, "lease.json")), { code: "ENOENT" }); + } finally { await realm.close(); await fixture.close(); } + } +}); + +test("recovers both sides of the authoritative promotion rename", async () => { + for (const authorityPromoted of [false, true]) { + const fixture = await createFixture(); + const configured = agent(fixture.root, `promoting-${authorityPromoted}`); + await prepareAgents([configured]); + const source = credential("source-before-promotion"); + const promoted = credential(`promotion-${authorityPromoted}`); + const sourceDigest = createHash("sha256").update(source).digest("hex"); + const promotedDigest = createHash("sha256").update(promoted).digest("hex"); + await writePrivate(path.join(fixture.durablePath, "auth.json"), authorityPromoted ? promoted : source); + await writePrivate(authPath(configured), promoted); + await writePrivate(path.join(fixture.durablePath, "lease.json"), `${JSON.stringify({ + agent_id: configured.id, promoted_digest: promotedDigest, source_digest: sourceDigest, state: "promoting", + version: "noopolis.daimon.grok-credential-lease.v1" + })}\n`); + const realm = await startGrokSubscriptionRealm([configured], fixture.options); + try { + await realm.withCredential(configured, async () => { + assert.equal(await readFile(authPath(configured), "utf8"), promoted); + }); + await assert.rejects(readFile(path.join(fixture.durablePath, "lease.json")), { code: "ENOENT" }); + } finally { await realm.close(); await fixture.close(); } + } +}); + +test("recovers injected crashes at both authoritative promotion fault points", async () => { + for (const faultPoint of ["promotion_prepared", "authority_replaced"] as const) { + const fixture = await createFixture(); + const configured = agent(fixture.root, `fault-${faultPoint}`); + await prepareAgents([configured]); + let fired = false; + let realm = await startGrokSubscriptionRealm([configured], { + ...fixture.options, + onTransitionForTest: (stage) => { + if (!fired && stage === faultPoint) { fired = true; throw new Error("injected crash"); } + } + }); + try { + await assert.rejects(realm.withCredential(configured, async () => { + await writePrivate(authPath(configured), credential(`rotated-${faultPoint}`)); + }), /injected crash/u); + assert.equal(fired, true); + await realm.close(); + realm = await startGrokSubscriptionRealm([configured], fixture.options); + await realm.withCredential(configured, async () => { + assert.equal(await readFile(authPath(configured), "utf8"), credential(`rotated-${faultPoint}`)); + }); + } finally { await realm.close().catch(() => undefined); await fixture.close(); } + } +}); + +test("imports an explicitly replaced bootstrap without relying on timestamps", async () => { + const fixture = await createFixture(); + const configured = agent(fixture.root, "publisher"); + await prepareAgents([configured]); + let realm = await startGrokSubscriptionRealm([configured], fixture.options); + try { + await realm.withCredential(configured, async () => { + await writePrivate(authPath(configured), credential("runtime-rotation")); + }); + await realm.close(); + await writePrivate(fixture.bootstrapPath, credential("operator-replacement")); + const past = new Date(Date.now() - 86_400_000); + await utimes(fixture.bootstrapPath, past, past); + realm = await startGrokSubscriptionRealm([configured], fixture.options); + await realm.withCredential(configured, async () => { + assert.equal(await readFile(authPath(configured), "utf8"), credential("operator-replacement")); + }); + } finally { + await realm.close().catch(() => undefined); + await fixture.close(); + } +}); + +test("rejects replaced control metadata without reflecting its contents", async () => { + const fixture = await createFixture(); + const configured = agent(fixture.root, "copydesk"); + await prepareAgents([configured]); + const realm = await startGrokSubscriptionRealm([configured], fixture.options); + await realm.close(); + const outside = path.join(fixture.root, "outside-control"); + await writePrivate(outside, "secret-control-canary"); + await rm(path.join(fixture.durablePath, "bootstrap.json")); + await symlink(outside, path.join(fixture.durablePath, "bootstrap.json")); + try { + await assert.rejects(startGrokSubscriptionRealm([configured], fixture.options), (error: Error) => { + assert.equal(error.message, "Grok subscription credential realm is unavailable or stale"); + assert.doesNotMatch(error.message, /secret-control-canary|outside-control|copydesk/u); + return true; + }); + } finally { + await fixture.close(); + } +}); + +test("fails closed instead of repairing linked durable authority", async () => { + const fixture = await createFixture(); + const configured = agent(fixture.root, "legal"); + await prepareAgents([configured]); + const realm = await startGrokSubscriptionRealm([configured], fixture.options); + await realm.close(); + await link( + path.join(fixture.durablePath, "auth.json"), + path.join(fixture.root, "linked-authority-canary") + ); + try { + await assert.rejects(startGrokSubscriptionRealm([configured], fixture.options), (error: Error) => { + assert.equal(error.message, "Grok subscription credential realm is unavailable or stale"); + assert.doesNotMatch(error.message, /linked-authority-canary|legal/u); + return true; + }); + } finally { + await fixture.close(); + } +}); + +test("holds one process-wide Grok realm lease", { skip: process.platform !== "linux" }, async () => { + const fixture = await createFixture({ realFlock: true }); + const configured = agent(fixture.root, "wire"); + await prepareAgents([configured]); + const first = await startGrokSubscriptionRealm([configured], fixture.options); + try { + await assert.rejects( + startGrokSubscriptionRealm([configured], fixture.options), + /unavailable or stale/u + ); + await first.close(); + const replacement = await startGrokSubscriptionRealm([configured], fixture.options); + await replacement.close(); + } finally { + await first.close().catch(() => undefined); + await fixture.close(); + } +}); + +async function createFixture(settings: { realFlock?: boolean } = {}): Promise<{ + bootstrapPath: string; + close(): Promise; + durablePath: string; + options: Parameters[1]; + root: string; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-realm-")); + const durablePath = path.join(root, "realm"); + const bootstrapPath = path.join(root, "bootstrap-auth"); + const fakeFlock = path.join(root, "fake-flock.mjs"); + await mkdir(durablePath, { mode: 0o700 }); + await writePrivate(bootstrapPath, credential("bootstrap")); + await writeFile(fakeFlock, "#!/usr/bin/env node\nprocess.exit(0);\n", { mode: 0o700 }); + await chmod(fakeFlock, 0o700); + return { + bootstrapPath, durablePath, root, + close: () => rm(root, { recursive: true, force: true }), + options: { + bootstrapPath, + durablePath, + ...(settings.realFlock ? {} : { flock: fakeFlock }) + } + }; +} + +async function prepareAgents(agents: readonly GrokAgent[]): Promise { + await Promise.all(agents.flatMap((configured) => [ + mkdir(path.dirname(authPath(configured)), { recursive: true, mode: 0o700 }), + mkdir(configured.workspacePath, { recursive: true, mode: 0o700 }) + ])); +} +function authPath(configured: GrokAgent): string { return path.join(configured.runtimeHomePath, ".grok", "auth.json"); } +async function writePrivate(file: string, value: string): Promise { await writeFile(file, value, { mode: 0o600 }); await chmod(file, 0o600); } diff --git a/src/runtime/grokSubscriptionRealm.ts b/src/runtime/grokSubscriptionRealm.ts new file mode 100644 index 0000000..d584b1f --- /dev/null +++ b/src/runtime/grokSubscriptionRealm.ts @@ -0,0 +1,399 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { spawn, type ChildProcess } from "node:child_process"; +import { lstat, open, rename, unlink } from "node:fs/promises"; +import path from "node:path"; +import { terminateChild, trackCliChild } from "../pi/cliProcess.js"; +import { GROK_SUBSCRIPTION_REALM } from "./contractManifest.js"; +import { hasRefreshablePortableCredential } from "./portableCredentialAuth.js"; +import { asGrokAuthenticationRejected } from "./grokAuthenticationError.js"; +import type { + GrokAgent, + GrokCredentialJournal as Journal, + GrokSubscriptionRealm, + GrokSubscriptionRealmOptions +} from "./grokSubscriptionRealmTypes.js"; +export type { GrokSubscriptionRealm, GrokSubscriptionRealmOptions } from "./grokSubscriptionRealmTypes.js"; +const AUTH_FILE = "auth.json"; +const JOURNAL_FILE = "lease.json"; +const STALE_FILE = "stale.json"; +const BOOTSTRAP_FILE = "bootstrap.json"; +const LEASE_FILE = ".daimon-lease"; +const MAX_CONTROL_BYTES = 4_096; +export async function startGrokSubscriptionRealm( + configuredAgents: readonly GrokAgent[], + options: GrokSubscriptionRealmOptions = {} +): Promise { + const durablePath = options.durablePath ?? GROK_SUBSCRIPTION_REALM.durableMountPath; + const bootstrapPath = options.bootstrapPath ?? GROK_SUBSCRIPTION_REALM.bootstrapMountPath; + await verifyPrivateDirectory(durablePath); + const lease = await acquireRealmLease(durablePath, options); + const agents = new Map(configuredAgents.map((agent) => [agent.id, agent])); + let tail: Promise = Promise.resolve(); + let closed = false; + let closePromise: Promise | undefined; + let bootstrapDigest: string; + try { + bootstrapDigest = await initializeRealm(durablePath, bootstrapPath, agents); + } catch (error) { + await terminateChild(lease).catch(() => undefined); + throw redact(error); + } + const withCredential = async (agent: GrokAgent, operation: () => Promise): Promise => { + if (closed || agents.get(agent.id)?.runtimeHomePath !== agent.runtimeHomePath) throw redact(); + let release!: () => void; + const prior = tail; + tail = new Promise((resolve) => { release = resolve; }); + await prior; + try { + if (closed) throw redact(); + return await runLeasedTurn(durablePath, bootstrapDigest, agent, operation, options.onTransitionForTest); + } finally { + release(); + } + }; + return { + withCredential, + close: () => closePromise ??= (async () => { + closed = true; + await tail; + await terminateChild(lease); + })() + }; +} + +async function initializeRealm( + durablePath: string, + bootstrapPath: string, + agents: ReadonlyMap +): Promise { + const bootstrap = await readCredential(bootstrapPath); + try { + const observedBootstrap = await readBootstrap(path.join(durablePath, BOOTSTRAP_FILE)); + const journal = await readJournal(path.join(durablePath, JOURNAL_FILE)); + if (journal !== undefined) { + await recoverJournal( + durablePath, + journal, + agents, + observedBootstrap?.bootstrap_digest ?? bootstrap.digest + ); + } + const stale = await readStale(path.join(durablePath, STALE_FILE)); + const authority = await readOptionalAuthority(path.join(durablePath, AUTH_FILE)); + const shouldImport = stale !== undefined + ? stale.bootstrap_digest !== bootstrap.digest + : authority === undefined || (observedBootstrap !== undefined + && observedBootstrap.bootstrap_digest !== bootstrap.digest); + authority?.bytes.fill(0); + if (shouldImport) { + await atomicCredentialWrite(path.join(durablePath, AUTH_FILE), bootstrap.bytes); + await unlink(path.join(durablePath, STALE_FILE)).catch(ignoreMissing); + await syncDirectory(durablePath); + } + if (shouldImport || observedBootstrap === undefined) { + await writeBootstrap(path.join(durablePath, BOOTSTRAP_FILE), bootstrap.digest); + } + if (await readStale(path.join(durablePath, STALE_FILE)) !== undefined) throw redact(); + return bootstrap.digest; + } finally { + bootstrap.bytes.fill(0); + } +} + +async function runLeasedTurn( + durablePath: string, + bootstrapDigest: string, + agent: GrokAgent, + operation: () => Promise, + onTransition?: GrokSubscriptionRealmOptions["onTransitionForTest"] +): Promise { + if (await readStale(path.join(durablePath, STALE_FILE)) !== undefined) throw redact(); + await verifyPrivateDirectory(path.dirname(path.join( + agent.runtimeHomePath, + GROK_SUBSCRIPTION_REALM.agentCredentialRelativePath + ))); + const target = path.join(agent.runtimeHomePath, GROK_SUBSCRIPTION_REALM.agentCredentialRelativePath); + await assertAbsent(target); + const authority = await readCredential(path.join(durablePath, AUTH_FILE)); + const journalPath = path.join(durablePath, JOURNAL_FILE); + try { + await writeJournal(journalPath, { agent_id: agent.id, source_digest: authority.digest, state: "preparing", version: "noopolis.daimon.grok-credential-lease.v1" }); + await atomicCredentialWrite(target, authority.bytes); + await writeJournal(journalPath, { agent_id: agent.id, source_digest: authority.digest, state: "active", version: "noopolis.daimon.grok-credential-lease.v1" }); + } finally { + authority.bytes.fill(0); + } + let result: T | undefined; + let operationError: unknown; + try { result = await operation(); } catch (error) { operationError = error; } + if (asGrokAuthenticationRejected(operationError) !== undefined) { + await unlink(target).catch(ignoreMissing); + await syncDirectory(path.dirname(target)); + await markStale(durablePath, bootstrapDigest); + throw redact(); + } + const candidate = await tryReadCredential(target); + if (candidate === undefined) { + await unlink(target).catch(ignoreMissing); + await syncDirectory(path.dirname(target)); + await markStale(durablePath, bootstrapDigest); + throw redact(operationError); + } + try { + await writeJournal(journalPath, { + agent_id: agent.id, + promoted_digest: candidate.digest, + source_digest: authority.digest, + state: "promoting", + version: "noopolis.daimon.grok-credential-lease.v1" + }); + await onTransition?.("promotion_prepared"); + await atomicCredentialWrite(path.join(durablePath, AUTH_FILE), candidate.bytes); + await onTransition?.("authority_replaced"); + await writeJournal(journalPath, { + agent_id: agent.id, + promoted_digest: candidate.digest, + source_digest: authority.digest, + state: "promoted", + version: "noopolis.daimon.grok-credential-lease.v1" + }); + await unlink(target).catch(ignoreMissing); + await syncDirectory(path.dirname(target)); + await unlink(journalPath).catch(ignoreMissing); + await syncDirectory(durablePath); + } finally { + candidate.bytes.fill(0); + } + if (operationError !== undefined) throw operationError; + return result as T; +} + +async function recoverJournal( + durablePath: string, + journal: Journal, + agents: ReadonlyMap, + bootstrapDigest: string +): Promise { + const agent = agents.get(journal.agent_id); + const target = agent === undefined ? undefined + : path.join(agent.runtimeHomePath, GROK_SUBSCRIPTION_REALM.agentCredentialRelativePath); + if (target !== undefined) await verifyPrivateDirectory(path.dirname(target)); + const authority = await readCredential(path.join(durablePath, AUTH_FILE)); + const authorityDigest = authority.digest; + try { + const expected = journal.state === "promoted" ? [journal.promoted_digest] + : journal.state === "promoting" ? [journal.source_digest, journal.promoted_digest] + : [journal.source_digest]; + if (!expected.includes(authorityDigest)) { + if (target !== undefined) { await unlink(target).catch(ignoreMissing); await syncDirectory(path.dirname(target)); } + await markStale(durablePath, bootstrapDigest); + return; + } + } finally { authority.bytes.fill(0); } + if (journal.state === "promoted") { + if (target !== undefined) { + await unlink(target).catch(ignoreMissing); + await syncDirectory(path.dirname(target)); + } + await unlink(path.join(durablePath, JOURNAL_FILE)).catch(ignoreMissing); + await syncDirectory(durablePath); + return; + } + if (journal.state === "preparing") { + if (target !== undefined) await unlink(target).catch(ignoreMissing); + await unlink(path.join(durablePath, JOURNAL_FILE)).catch(ignoreMissing); + await syncDirectory(durablePath); + return; + } + const candidate = target === undefined ? undefined : await tryReadCredential(target); + if (candidate === undefined) { + if (target !== undefined) { + await unlink(target).catch(ignoreMissing); + await syncDirectory(path.dirname(target)); + } + await markStale(durablePath, bootstrapDigest); + return; + } + try { + if (journal.state === "promoting" && candidate.digest !== journal.promoted_digest) { + await markStale(durablePath, bootstrapDigest); + return; + } + if (journal.state === "active") await writeJournal(path.join(durablePath, JOURNAL_FILE), { ...journal, promoted_digest: candidate.digest, state: "promoting" }); + if (authorityDigest === journal.source_digest) { + await atomicCredentialWrite(path.join(durablePath, AUTH_FILE), candidate.bytes); + } + await writeJournal(path.join(durablePath, JOURNAL_FILE), { + ...journal, promoted_digest: candidate.digest, state: "promoted" + }); + await unlink(target!).catch(ignoreMissing); + await syncDirectory(path.dirname(target!)); + await unlink(path.join(durablePath, JOURNAL_FILE)).catch(ignoreMissing); + await syncDirectory(durablePath); + } finally { + candidate.bytes.fill(0); + } +} + +async function markStale(durablePath: string, bootstrapDigest: string): Promise { + await atomicJsonWrite(path.join(durablePath, STALE_FILE), { + bootstrap_digest: bootstrapDigest, + version: "noopolis.daimon.grok-credential-stale.v1" + }); + await unlink(path.join(durablePath, JOURNAL_FILE)).catch(ignoreMissing); + await syncDirectory(durablePath); +} + +async function readCredential(file: string): Promise<{ bytes: Buffer; digest: string }> { + let handle: Awaited> | undefined; + try { + const before = await lstat(file); + assertCredentialIdentity(before); + handle = await open(file, constants.O_RDONLY | noFollow()); + const opened = await handle.stat(); + assertCredentialIdentity(opened); + if (!sameIdentity(before, opened)) throw new Error("changed"); + const bytes = await handle.readFile(); + const after = await handle.stat(); + if (!sameIdentity(opened, after) || !hasRefreshablePortableCredential("grok", bytes)) { + bytes.fill(0); throw new Error("invalid"); + } + return { bytes, digest: createHash("sha256").update(bytes).digest("hex") }; + } catch { + throw redact(); + } finally { + await handle?.close().catch(() => undefined); + } +} +async function tryReadCredential(file: string): Promise> | undefined> { + try { return await readCredential(file); } catch { return undefined; } +} +async function readOptionalAuthority(file: string): Promise> | undefined> { + try { + return await readCredential(file); + } catch { + try { + await lstat(file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + } + throw redact(); + } +} +async function atomicCredentialWrite(file: string, bytes: Uint8Array): Promise { + await atomicWrite(file, bytes, GROK_SUBSCRIPTION_REALM.fileMode); + const checked = await readCredential(file); + checked.bytes.fill(0); +} +async function atomicJsonWrite(file: string, value: unknown): Promise { + await atomicWrite(file, Buffer.from(`${JSON.stringify(value)}\n`), 0o600); +} +async function atomicWrite(file: string, bytes: Uint8Array, mode: number): Promise { + const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`); + let handle: Awaited> | undefined; + try { + handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow(), mode); + await handle.writeFile(bytes); await handle.chmod(mode); await handle.sync(); await handle.close(); handle = undefined; + await rename(temporary, file); await syncDirectory(path.dirname(file)); + } catch { throw redact(); } finally { + await handle?.close().catch(() => undefined); await unlink(temporary).catch(() => undefined); + } +} +async function writeJournal(file: string, value: Journal): Promise { await atomicJsonWrite(file, value); } +async function readJournal(file: string): Promise { + return readPrivateJson(file, (value) => { + const candidate = value as Partial; + if (candidate.version !== "noopolis.daimon.grok-credential-lease.v1" || !["active", "preparing", "promoted", "promoting"].includes(candidate.state ?? "") + || typeof candidate.agent_id !== "string" || !candidate.agent_id || !/^[a-f0-9]{64}$/u.test(candidate.source_digest ?? "") + || (["promoted", "promoting"].includes(candidate.state ?? "") && !/^[a-f0-9]{64}$/u.test(candidate.promoted_digest ?? ""))) throw new Error("invalid"); + return candidate as Journal; + }); +} +async function readStale(file: string): Promise<{ bootstrap_digest: string } | undefined> { + return readPrivateJson(file, (value) => { + const candidate = value as Record; + if (candidate.version !== "noopolis.daimon.grok-credential-stale.v1" || typeof candidate.bootstrap_digest !== "string" || !/^[a-f0-9]{64}$/u.test(candidate.bootstrap_digest)) throw new Error("invalid"); + return { bootstrap_digest: candidate.bootstrap_digest }; + }); +} +async function readBootstrap(file: string): Promise<{ bootstrap_digest: string } | undefined> { + return readPrivateJson(file, (value) => { + const candidate = value as Record; + if (candidate.version !== "noopolis.daimon.grok-bootstrap-observation.v1" || typeof candidate.bootstrap_digest !== "string" || !/^[a-f0-9]{64}$/u.test(candidate.bootstrap_digest)) throw new Error("invalid"); + return { bootstrap_digest: candidate.bootstrap_digest }; + }); +} +async function writeBootstrap(file: string, bootstrapDigest: string): Promise { + await atomicJsonWrite(file, { + bootstrap_digest: bootstrapDigest, + version: "noopolis.daimon.grok-bootstrap-observation.v1" + }); +} +async function readPrivateJson(file: string, parse: (value: unknown) => T): Promise { + let handle: Awaited> | undefined; + let bytes: Buffer | undefined; + try { + const before = await lstat(file); + assertPrivateFileIdentity(before, MAX_CONTROL_BYTES); + handle = await open(file, constants.O_RDONLY | noFollow()); + const opened = await handle.stat(); + assertPrivateFileIdentity(opened, MAX_CONTROL_BYTES); + if (!sameIdentity(before, opened)) throw new Error("changed"); + bytes = await handle.readFile(); + const after = await handle.stat(); + if (!sameIdentity(opened, after)) throw new Error("changed"); + return parse(JSON.parse(bytes.toString("utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw redact(); + } finally { + bytes?.fill(0); + await handle?.close().catch(() => undefined); + } +} +async function verifyPrivateDirectory(directory: string): Promise { + try { const entry = await lstat(directory); if (!entry.isDirectory() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 0o777) !== GROK_SUBSCRIPTION_REALM.directoryMode) throw new Error("unsafe"); } + catch { throw redact(); } +} + +async function assertAbsent(file: string): Promise { + try { + await lstat(file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + } + throw redact(); +} + +async function acquireRealmLease(durablePath: string, options: GrokSubscriptionRealmOptions): Promise { + let lock: Awaited> | undefined; + let child: ChildProcess | undefined; + try { + lock = await open(path.join(durablePath, LEASE_FILE), constants.O_CREAT | constants.O_RDWR | noFollow(), 0o600); + const entry = await lock.stat(); + if (!entry.isFile() || entry.uid !== process.getuid?.() || entry.nlink !== 1 || (entry.mode & 0o777) !== 0o600) throw new Error("unsafe"); + child = trackCliChild(spawn(options.shell ?? "/bin/sh", ["-c", "\"$1\" --exclusive --nonblock --conflict-exit-code 73 3 || exit 73; printf 'ready\\n'; IFS= read -r _hold || :", "daimon-grok-lease", options.flock ?? "flock"], { detached: process.platform !== "win32", env: { LANG: "C", LC_ALL: "C", PATH: process.env.PATH }, stdio: ["pipe", "pipe", "ignore", lock.fd] })); + await waitForReady(child); return child; + } catch { + if (child !== undefined) await terminateChild(child).catch(() => undefined); + throw redact(); + } finally { await lock?.close().catch(() => undefined); } +} + +async function waitForReady(child: ChildProcess): Promise { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout")), 5_000); + child.stdout?.once("data", (value: Buffer) => { clearTimeout(timer); value.toString("utf8") === "ready\n" ? resolve() : reject(new Error("invalid")); }); + child.once("close", () => { clearTimeout(timer); reject(new Error("closed")); }); + child.once("error", () => { clearTimeout(timer); reject(new Error("failed")); }); + }); +} + +async function syncDirectory(directory: string): Promise { const handle = await open(directory, constants.O_RDONLY); try { await handle.sync(); } finally { await handle.close(); } } +const ignoreMissing = (error: unknown): void => { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }; +const noFollow = (): number => (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; +const sameIdentity = (left: Awaited>, right: Awaited>): boolean => left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.nlink === right.nlink && left.size === right.size && left.uid === right.uid && left.mtimeMs === right.mtimeMs; +const assertCredentialIdentity = (entry: Awaited>): void => { if (!entry.isFile() || entry.isSymbolicLink() || Number(entry.uid) !== process.getuid?.() || Number(entry.nlink) !== 1 || (Number(entry.mode) & 0o777) !== GROK_SUBSCRIPTION_REALM.fileMode || Number(entry.size) < 1 || Number(entry.size) > GROK_SUBSCRIPTION_REALM.maxCredentialBytes) throw new Error("unsafe"); }; +const assertPrivateFileIdentity = (entry: Awaited>, maxBytes: number): void => { if (!entry.isFile() || entry.isSymbolicLink() || Number(entry.uid) !== process.getuid?.() || Number(entry.nlink) !== 1 || (Number(entry.mode) & 0o777) !== 0o600 || Number(entry.size) < 1 || Number(entry.size) > maxBytes) throw new Error("unsafe"); }; +const redact = (_cause?: unknown): Error => new Error("Grok subscription credential realm is unavailable or stale"); diff --git a/src/runtime/grokSubscriptionRealmTypes.ts b/src/runtime/grokSubscriptionRealmTypes.ts new file mode 100644 index 0000000..27c3a7e --- /dev/null +++ b/src/runtime/grokSubscriptionRealmTypes.ts @@ -0,0 +1,25 @@ +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +export type GrokAgent = OrganizationRuntimeAgentConfig & { engine: { kind: "grok" } }; + +export type GrokCredentialJournal = Readonly<{ + agent_id: string; + promoted_digest?: string; + source_digest: string; + state: "active" | "preparing" | "promoted" | "promoting"; + version: "noopolis.daimon.grok-credential-lease.v1"; +}>; + +export type GrokSubscriptionRealm = Readonly<{ + withCredential(agent: GrokAgent, operation: () => Promise): Promise; + close(): Promise; +}>; + +export type GrokSubscriptionRealmOptions = Readonly<{ + bootstrapPath?: string; + durablePath?: string; + flock?: string; + /** @internal deterministic crash-transition test seam. */ + onTransitionForTest?: (stage: "authority_replaced" | "promotion_prepared") => Promise | void; + shell?: string; +}>; diff --git a/src/runtime/portableCredentialAuth.ts b/src/runtime/portableCredentialAuth.ts new file mode 100644 index 0000000..1cd4a68 --- /dev/null +++ b/src/runtime/portableCredentialAuth.ts @@ -0,0 +1,84 @@ +export type PortableCredentialKind = "codex" | "grok"; + +/** Validates only the bounded provider-native shape needed for refresh. */ +export function hasRefreshablePortableCredential( + engine: PortableCredentialKind, + bytes: Uint8Array +): boolean { + let value: unknown; + try { + value = JSON.parse(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf8")); + } catch { + return false; + } + const pair = engine === "grok" ? grokTokenPair(value) : tokenPair(value); + if (pair.access === undefined || pair.refresh === undefined) return false; + if (engine !== "grok") return true; + const candidate = firstString(grokTokenEntry(value), ["expires_at"]); + return candidate !== undefined && Number.isFinite(Date.parse(candidate)); +} + +/** Returns only the provider-native bearer values for exact in-process redaction. */ +export function portableCredentialSecretValues( + engine: PortableCredentialKind, + bytes: Uint8Array +): readonly string[] { + let value: unknown; + try { + value = JSON.parse(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf8")); + } catch { + return []; + } + const pair = engine === "grok" ? grokTokenPair(value) : tokenPair(value); + return [...new Set([pair.access, pair.refresh].filter((entry): entry is string => entry !== undefined))]; +} + +function tokenPair(value: unknown): { access?: string; refresh?: string } { + const source = object(value); + const nested = object(source?.tokens) ?? source; + return { + access: firstNonBlankString(nested, ["access_token", "accessToken", "token"]), + refresh: firstNonBlankString(nested, ["refresh_token", "refreshToken"]) + }; +} + +function grokTokenEntry(value: unknown): Record | undefined { + const source = object(value); + if (source === undefined) return undefined; + for (const [key, entry] of Object.entries(source)) { + if (/^https:\/\/auth\.x\.ai::/u.test(key)) return object(entry); + } + return undefined; +} + +function grokTokenPair(value: unknown): { access?: string; refresh?: string } { + const entry = grokTokenEntry(value); + return { + access: firstNonBlankString(entry, ["key"]), + refresh: firstNonBlankString(entry, ["refresh_token"]) + }; +} + +function object(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function firstString( + value: Record | undefined, + names: readonly string[] +): string | undefined { + for (const name of names) { + if (typeof value?.[name] === "string" && value[name]!.length > 0) return value[name] as string; + } + return undefined; +} + +function firstNonBlankString( + value: Record | undefined, + names: readonly string[] +): string | undefined { + const candidate = firstString(value, names); + return candidate !== undefined && candidate.trim().length > 0 ? candidate : undefined; +} diff --git a/src/runtime/portableCredentialMaterial.test.ts b/src/runtime/portableCredentialMaterial.test.ts new file mode 100644 index 0000000..eb563d2 --- /dev/null +++ b/src/runtime/portableCredentialMaterial.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { materializePortableCredential } from "./portableCredentialMaterial.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +const agent = (root: string, kind: "codex"): OrganizationRuntimeAgentConfig & { + engine: { kind: "codex" }; +} => ({ + id: `agent:${kind}`, + name: kind, + instructions: "Work.", + workspacePath: path.join(root, "workspace"), + runtimeHomePath: path.join(root, "runtime"), + engine: { kind } +}); + +test("materializes an opaque portable credential with private runtime-owned modes", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-portable-credential-")); + const config = agent(root, "codex"); + const inbound = path.join(config.runtimeHomePath, ".daimon-inbound"); + const source = path.join(inbound, "codex-auth"); + const destination = path.join(config.runtimeHomePath, ".codex", "auth.json"); + try { + await mkdir(inbound, { recursive: true, mode: 0o700 }); + await writeFile(source, "opaque-codex-credential", { mode: 0o600 }); + await chmod(source, 0o600); + + assert.equal(await materializePortableCredential(config, config.runtimeHomePath), "created"); + assert.equal(await readFile(destination, "utf8"), "opaque-codex-credential"); + assert.equal((await lstat(path.dirname(destination))).mode & 0o777, 0o700); + assert.equal((await lstat(destination)).mode & 0o777, 0o600); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("preserves a newer runtime refresh and imports a later ingress refresh", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-portable-refresh-")); + const config = agent(root, "codex"); + const inbound = path.join(config.runtimeHomePath, ".daimon-inbound"); + const source = path.join(inbound, "codex-auth"); + const destination = path.join(config.runtimeHomePath, ".codex", "auth.json"); + try { + await mkdir(inbound, { recursive: true, mode: 0o700 }); + await writeFile(source, "initial-ingress", { mode: 0o600 }); + await chmod(source, 0o600); + assert.equal(await materializePortableCredential(config, config.runtimeHomePath), "created"); + + await writeFile(destination, "runtime-refreshed", { mode: 0o600 }); + await chmod(destination, 0o600); + await utimes(source, new Date(2_000), new Date(2_000)); + await utimes(destination, new Date(3_000), new Date(3_000)); + assert.equal(await materializePortableCredential(config, config.runtimeHomePath), "preserved"); + assert.equal(await readFile(destination, "utf8"), "runtime-refreshed"); + + await writeFile(source, "operator-refreshed", { mode: 0o600 }); + await chmod(source, 0o600); + await utimes(source, new Date(4_000), new Date(4_000)); + assert.equal(await materializePortableCredential(config, config.runtimeHomePath), "refreshed"); + assert.equal(await readFile(destination, "utf8"), "operator-refreshed"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejects unsafe ingress without disclosing its path or contents", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-portable-unsafe-")); + const config = agent(root, "codex"); + const inbound = path.join(config.runtimeHomePath, ".daimon-inbound"); + const source = path.join(inbound, "codex-auth"); + const outside = path.join(root, "outside-auth"); + try { + await mkdir(inbound, { recursive: true, mode: 0o700 }); + await writeFile(outside, "must-not-leak", { mode: 0o600 }); + await symlink(outside, source); + await assert.rejects( + materializePortableCredential(config, config.runtimeHomePath), + (error: Error) => { + assert.match(error.message, /agent:codex codex credential materialization failed/); + assert.doesNotMatch(error.message, /outside-auth|must-not-leak/); + return true; + } + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/runtime/portableCredentialMaterial.ts b/src/runtime/portableCredentialMaterial.ts new file mode 100644 index 0000000..0dc0a71 --- /dev/null +++ b/src/runtime/portableCredentialMaterial.ts @@ -0,0 +1,194 @@ +import { constants } from "node:fs"; +import { chmod, lstat, mkdir, open, rename, unlink } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; + +import { ENGINE_CREDENTIAL_MATERIAL } from "./contractManifest.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +const MAX_CREDENTIAL_BYTES = 64 * 1024; +type PortableAgent = OrganizationRuntimeAgentConfig & { engine: { kind: "codex" } }; +type FileIdentity = Readonly<{ + dev: number; + ino: number; + mode: number; + mtimeMs: number; + size: number; + uid: number; +}>; + +/** + * Seeds a runtime-writable CLI credential from Daimon's read-only ingress. + * A newer runtime copy wins so a CLI refresh survives process/container + * restarts; a later operator refresh of the ingress is imported on restart. + */ +export async function materializePortableCredential( + configuredAgent: OrganizationRuntimeAgentConfig, + runtimeHomePath: string +): Promise<"created" | "preserved" | "refreshed"> { + if (configuredAgent.engine.kind !== "codex") { + throw new Error(`agent ${configuredAgent.id} ${configuredAgent.engine.kind} has no portable credential material`); + } + const agent = configuredAgent as PortableAgent; + const rule = ENGINE_CREDENTIAL_MATERIAL[agent.engine.kind]; + const sourcePath = contained(runtimeHomePath, rule.sourceRelativePath); + const destinationPath = contained(runtimeHomePath, rule.destinationRelativePath); + const destinationDirectory = path.dirname(destinationPath); + await assertPrivateDirectory(path.dirname(sourcePath), rule.directoryMode, agent, false); + const source = await readCredential(sourcePath, rule.fileMode, agent); + await prepareDestinationDirectory(destinationDirectory, rule.directoryMode, agent); + + const existing = await existingCredential(destinationPath, rule.fileMode, agent); + if (existing !== undefined && existing.mtimeMs >= source.identity.mtimeMs) return "preserved"; + + const temporaryPath = path.join(destinationDirectory, `.${path.basename(destinationPath)}.${randomUUID()}.tmp`); + let temporary: Awaited> | undefined; + try { + temporary = await open( + temporaryPath, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow(), + rule.fileMode + ); + await temporary.writeFile(source.bytes); + await temporary.chmod(rule.fileMode); + await temporary.sync(); + await temporary.close(); + temporary = undefined; + await rename(temporaryPath, destinationPath); + await assertCredential(destinationPath, rule.fileMode, agent); + return existing === undefined ? "created" : "refreshed"; + } catch (error) { + throw unavailable(agent, error); + } finally { + await temporary?.close().catch(() => undefined); + await unlink(temporaryPath).catch(() => undefined); + } +} + +async function readCredential( + filePath: string, + mode: number, + agent: PortableAgent +): Promise<{ bytes: Buffer; identity: FileIdentity }> { + let handle: Awaited> | undefined; + try { + const before = await assertCredential(filePath, mode, agent); + handle = await open(filePath, constants.O_RDONLY | noFollow()); + const opened = identity(await handle.stat()); + if (!sameIdentity(before, opened)) throw new Error("credential changed during import"); + const bytes = await handle.readFile(); + const after = identity(await handle.stat()); + if (!sameIdentity(before, after) || bytes.length !== before.size) { + throw new Error("credential changed during import"); + } + return { bytes, identity: before }; + } catch (error) { + throw unavailable(agent, error); + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function prepareDestinationDirectory( + directoryPath: string, + mode: number, + agent: PortableAgent +): Promise { + try { + await mkdir(directoryPath, { mode }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw unavailable(agent, error); + } + await assertPrivateDirectory(directoryPath, mode, agent, true); +} + +async function assertPrivateDirectory( + directoryPath: string, + mode: number, + agent: PortableAgent, + repairMode: boolean +): Promise { + try { + const entry = await lstat(directoryPath); + if (!entry.isDirectory() || entry.isSymbolicLink() || entry.uid !== process.getuid?.()) { + throw new Error("unsafe credential directory"); + } + if (repairMode && (entry.mode & 0o777) !== mode) await chmod(directoryPath, mode); + const secured = await lstat(directoryPath); + if (!secured.isDirectory() || secured.isSymbolicLink() || secured.uid !== process.getuid?.() + || (secured.mode & 0o777) !== mode) throw new Error("unsafe credential directory"); + } catch (error) { + throw unavailable(agent, error); + } +} + +async function existingCredential( + filePath: string, + mode: number, + agent: PortableAgent +): Promise { + try { + return await assertCredential(filePath, mode, agent); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +async function assertCredential( + filePath: string, + mode: number, + agent: PortableAgent +): Promise { + let entry: Awaited>; + try { + entry = await lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw error; + throw unavailable(agent, error); + } + if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() + || entry.nlink !== 1 || (entry.mode & 0o777) !== mode + || entry.size === 0 || entry.size > MAX_CREDENTIAL_BYTES) { + throw unavailable(agent, new Error("unsafe credential artifact")); + } + return identity(entry); +} + +function contained(root: string, relative: string): string { + const candidate = path.resolve(root, relative); + const relation = path.relative(path.resolve(root), candidate); + if (!relation || relation === ".." || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) { + throw new Error("Daimon credential contract escapes the runtime home"); + } + return candidate; +} + +function identity(value: Awaited>): FileIdentity { + const numeric = value as typeof value & { + dev: number; + ino: number; + mode: number; + mtimeMs: number; + size: number; + uid: number; + }; + return { + dev: numeric.dev, + ino: numeric.ino, + mode: numeric.mode & 0o7777, + mtimeMs: numeric.mtimeMs, + size: numeric.size, + uid: numeric.uid + }; +} +function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode + && left.mtimeMs === right.mtimeMs && left.size === right.size && left.uid === right.uid; +} +function noFollow(): number { + return (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; +} +function unavailable(agent: PortableAgent, _cause: unknown): Error { + return new Error(`agent ${agent.id} ${agent.engine.kind} credential materialization failed`); +} From 78ddc0e493a708f5b527e5cb79d3da9137ae123e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:29 +0200 Subject: [PATCH 04/24] feat(broker): add native engine broker ABI --- src/runtime/engineBrokerNativeClient.test.ts | 27 ++ src/runtime/engineBrokerNativeClient.ts | 37 ++ src/runtime/engineBrokerProtocol.test.ts | 27 ++ src/runtime/engineBrokerProtocol.ts | 103 +++++ src/runtime/native/AGENTS.md | 13 + src/runtime/native/CLAUDE.md | 1 + src/runtime/native/Dockerfile | 4 + src/runtime/native/Dockerfile.integration | 10 + .../artifacts/daimon-engine-broker-arm64 | Bin 0 -> 67456 bytes ...daimon-engine-broker-arm64.provenance.json | 1 + .../native/artifacts/daimon-engine-broker-x64 | Bin 0 -> 55392 bytes .../daimon-engine-broker-x64.provenance.json | 1 + src/runtime/native/build.mjs | 28 ++ src/runtime/native/copyArtifact.mjs | 15 + src/runtime/native/engineBrokerLauncher.c | 4 + src/runtime/native/engineBrokerLauncher.h | 92 +++++ .../native/engineBrokerLauncher.integration.c | 4 + .../native/engineBrokerLauncherCore.inc | 348 ++++++++++++++++ .../engineBrokerLauncherIntegrationCore.inc | 390 ++++++++++++++++++ ...ngineBrokerLauncherIntegrationLauncher.inc | 147 +++++++ .../engineBrokerLauncherIntegrationMain.inc | 68 +++ .../engineBrokerLauncherIntegrationRelay.inc | 246 +++++++++++ .../native/engineBrokerLauncherMain.inc | 52 +++ .../native/engineBrokerLauncherModes.inc | 251 +++++++++++ .../native/engineBrokerLauncherServer.inc | 217 ++++++++++ src/runtime/native/fixtureWorker.c | 8 + src/runtime/native/verifyArtifacts.mjs | 18 + 27 files changed, 2112 insertions(+) create mode 100644 src/runtime/engineBrokerNativeClient.test.ts create mode 100644 src/runtime/engineBrokerNativeClient.ts create mode 100644 src/runtime/engineBrokerProtocol.test.ts create mode 100644 src/runtime/engineBrokerProtocol.ts create mode 100644 src/runtime/native/AGENTS.md create mode 120000 src/runtime/native/CLAUDE.md create mode 100644 src/runtime/native/Dockerfile create mode 100644 src/runtime/native/Dockerfile.integration create mode 100755 src/runtime/native/artifacts/daimon-engine-broker-arm64 create mode 100644 src/runtime/native/artifacts/daimon-engine-broker-arm64.provenance.json create mode 100755 src/runtime/native/artifacts/daimon-engine-broker-x64 create mode 100644 src/runtime/native/artifacts/daimon-engine-broker-x64.provenance.json create mode 100644 src/runtime/native/build.mjs create mode 100644 src/runtime/native/copyArtifact.mjs create mode 100644 src/runtime/native/engineBrokerLauncher.c create mode 100644 src/runtime/native/engineBrokerLauncher.h create mode 100644 src/runtime/native/engineBrokerLauncher.integration.c create mode 100644 src/runtime/native/engineBrokerLauncherCore.inc create mode 100644 src/runtime/native/engineBrokerLauncherIntegrationCore.inc create mode 100644 src/runtime/native/engineBrokerLauncherIntegrationLauncher.inc create mode 100644 src/runtime/native/engineBrokerLauncherIntegrationMain.inc create mode 100644 src/runtime/native/engineBrokerLauncherIntegrationRelay.inc create mode 100644 src/runtime/native/engineBrokerLauncherMain.inc create mode 100644 src/runtime/native/engineBrokerLauncherModes.inc create mode 100644 src/runtime/native/engineBrokerLauncherServer.inc create mode 100644 src/runtime/native/fixtureWorker.c create mode 100644 src/runtime/native/verifyArtifacts.mjs diff --git a/src/runtime/engineBrokerNativeClient.test.ts b/src/runtime/engineBrokerNativeClient.test.ts new file mode 100644 index 0000000..754dcfa --- /dev/null +++ b/src/runtime/engineBrokerNativeClient.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { decodeNativeBrokerResult,encodeNativeBrokerTurn,ENGINE_BROKER_NATIVE_RESULT_BYTES,NativeBrokerTurnFailure } from "./engineBrokerNativeClient.js"; + +const turnId="turn-1"; +function frame(values:Readonly<{status?:number;uid?:number;pid?:number;exit?:number;signal?:number;ticks?:bigint;stage?:number;failure?:number;profile?:number;reserved?:number;text?:string}>={}):Buffer{ + const text=Buffer.from(values.text??"");const out=Buffer.alloc(ENGINE_BROKER_NATIVE_RESULT_BYTES+text.length);out.writeUInt32LE(2,0);out.writeUInt32LE(values.status??0,4);out.writeUInt32LE(values.uid??2200,8);out.writeUInt32LE(text.length,12);out.writeInt32LE(values.pid??42,16);out.writeInt32LE(values.exit??0,20);out.writeInt32LE(values.signal??0,24);out.writeBigUInt64LE(values.ticks??123n,32);out.write(turnId,40);out.writeUInt32LE(values.stage??7,108);out.writeUInt32LE(values.failure??0,112);out.writeUInt32LE(values.profile??0,116);out.writeUInt32LE(values.reserved??0,120);text.copy(out,ENGINE_BROKER_NATIVE_RESULT_BYTES);return out; +} + +test("encodes ABI v2 and decodes a closed successful result",()=>{ + const encoded=encodeNativeBrokerTurn({slot:1,requestId:"request-1",turnId,agentId:"agent-1",wakeId:"wake-1",prompt:"work",providerCapability:"provider-cap",mcpCapability:"mcp-cap"}); + assert.equal(encoded.readUInt32LE(0),2);assert.equal(decodeNativeBrokerResult(frame({text:"done"}),turnId).text,"done");encoded.fill(0); +}); + +test("returns bounded typed diagnostics for closed native failures",()=>{ + for(const value of [ + {status:1,stage:4,failure:4,pid:0,uid:0,ticks:0n,exit:-1}, + {status:2,stage:6,failure:5,exit:127}, + {status:3,stage:7,failure:7}, + {status:4,stage:6,failure:8,signal:15}, + ])assert.throws(()=>decodeNativeBrokerResult(frame(value),turnId),(error:unknown)=>error instanceof NativeBrokerTurnFailure&&error.diagnostic.stage!=="none"); +}); + +test("rejects unknown, reserved, output-bearing, and cross-class failure frames",()=>{ + for(const value of [{status:9},{reserved:1},{status:1,stage:7,failure:4,pid:0,uid:0,ticks:0n},{status:2,stage:6,failure:6,text:"secret"}])assert.throws(()=>decodeNativeBrokerResult(frame(value),turnId),/^Error: engine broker turn failed$/u); + for(const offset of [28,31,105,107,124,127]){const hostile=frame({text:"done"});hostile[offset]=1;assert.throws(()=>decodeNativeBrokerResult(hostile,turnId),/^Error: engine broker turn failed$/u);} +}); diff --git a/src/runtime/engineBrokerNativeClient.ts b/src/runtime/engineBrokerNativeClient.ts new file mode 100644 index 0000000..8d905bd --- /dev/null +++ b/src/runtime/engineBrokerNativeClient.ts @@ -0,0 +1,37 @@ +import { spawn } from "node:child_process"; +import { terminateChild, trackCliChild } from "../pi/cliProcess.js"; + +export const ENGINE_BROKER_NATIVE_REQUEST_BYTES = 396; +export const ENGINE_BROKER_NATIVE_RESULT_BYTES = 128; +const MAX_PROMPT = 65_536, MAX_CAPABILITY = 4_096, MAX_OUTPUT = 65_536; +const statuses = ["ok", "prelaunch_failed", "worker_failed", "output_failed", "cancelled"] as const; +const stages = ["none", "peer", "request", "registration", "executable", "exec", "wait", "output", "attestation"] as const; +const failures = ["none", "peer", "protocol", "registration", "executable", "exec", "wait", "output_limit", "cancelled", "profile_missing", "profile_invalid"] as const; + +export interface NativeBrokerDiagnostic { exitCode:number;failureClass:typeof failures[number];profileApplied:boolean;stage:typeof stages[number];startTicks:string;status:typeof statuses[number];termSignal:number;workerPid:number;workerUid:number } +export class NativeBrokerTurnFailure extends Error { constructor(readonly diagnostic:NativeBrokerDiagnostic){super("engine broker turn failed");} } +export type NativeBrokerTurn = Readonly<{slot:number;requestId:string;turnId:string;agentId:string;wakeId:string;prompt:string;providerCapability:string;mcpCapability:string}>; +export interface NativeBrokerTurnResult {text:string;workerPid:number;workerUid:number;startTicks:bigint;diagnostic:NativeBrokerDiagnostic} + +export async function runNativeBrokerTurn(executable:string,input:NativeBrokerTurn,signal?:AbortSignal):Promise>{ + const frame=encodeNativeBrokerTurn(input),child=trackCliChild(spawn(executable,["--client"],{detached:process.platform!=="win32",env:{LANG:"C",LC_ALL:"C",TZ:"UTC"},stdio:["pipe","pipe","ignore"],...(signal===undefined?{}:{signal})}));const chunks:Buffer[]=[];let bytes=0; + child.stdout!.on("data",(chunk:Buffer)=>{bytes+=chunk.length;if(bytes<=ENGINE_BROKER_NATIVE_RESULT_BYTES+MAX_OUTPUT)chunks.push(chunk);});child.stdin!.end(frame);frame.fill(0); + try{const code=await new Promise((resolve,reject)=>{child.once("error",reject);child.once("exit",resolve);});if(code!==0||bytes>ENGINE_BROKER_NATIVE_RESULT_BYTES+MAX_OUTPUT)throw new Error();return decodeNativeBrokerResult(Buffer.concat(chunks),input.turnId);}catch(error){if(error instanceof NativeBrokerTurnFailure)throw error;throw new Error("engine broker turn failed");}finally{await terminateChild(child).catch(()=>undefined);} +} +export function encodeNativeBrokerTurn(input:NativeBrokerTurn):Buffer{if(!Number.isInteger(input.slot)||input.slot<0)throw new TypeError("invalid engine broker turn");const p=Buffer.from(input.prompt),provider=Buffer.from(input.providerCapability),mcp=Buffer.from(input.mcpCapability);if(p.length<1||p.length>MAX_PROMPT||provider.length<1||provider.length>MAX_CAPABILITY||mcp.length<1||mcp.length>MAX_CAPABILITY||provider.equals(mcp))throw new TypeError("invalid engine broker turn");const c=Buffer.alloc(4+provider.length+mcp.length);c.writeUInt16LE(provider.length,0);provider.copy(c,2);c.writeUInt16LE(mcp.length,2+provider.length);mcp.copy(c,4+provider.length);const frame=Buffer.alloc(ENGINE_BROKER_NATIVE_REQUEST_BYTES+8+p.length+c.length);frame.writeUInt32LE(2,0);frame.writeUInt32LE(input.slot,4);field(frame,8,65,input.requestId);field(frame,73,65,input.turnId);field(frame,138,129,input.agentId);field(frame,267,129,input.wakeId);let o=ENGINE_BROKER_NATIVE_REQUEST_BYTES;frame.writeUInt32LE(p.length,o);o+=4;p.copy(frame,o);o+=p.length;frame.writeUInt32LE(c.length,o);o+=4;c.copy(frame,o);p.fill(0);provider.fill(0);mcp.fill(0);c.fill(0);return frame;} + +export function decodeNativeBrokerResult(output:Buffer,turnId:string):NativeBrokerTurnResult{ + if(output.lengthbytes.every((byte)=>byte===0)); + if(output.length!==ENGINE_BROKER_NATIVE_RESULT_BYTES+length||output.readUInt32LE(0)!==2||status>=statuses.length||stage>=stages.length||failure>=failures.length||profile>1||reserved!==0||!paddingZero||observed!==turnId||length>MAX_OUTPUT)throw new Error("engine broker turn failed"); + const diagnostic:NativeBrokerDiagnostic={status:statuses[status]!,stage:stages[stage]!,failureClass:failures[failure]!,profileApplied:profile===1,exitCode,termSignal,workerPid:pid,workerUid:uid,startTicks:ticks.toString()}; + const success=status===0&&stage===7&&failure===0&&profile===0&&pid>0&&uid>=2200&&ticks>0n&&exitCode===0&&termSignal===0; + const prelaunch=status===1&&stage>=1&&stage<=5&&failure>=1&&failure<=5&&profile===0&&pid===0&&uid===0&&ticks===0n; + const worker=status===2&&stage===6&&(failure===5||failure===6)&&profile===0&&pid>0&&uid>=2200&&ticks>0n; + const outputFailure=status===3&&stage===7&&failure===7&&profile===0&&pid>0&&uid>=2200&&ticks>0n; + const cancelled=status===4&&stage===6&&failure===8&&profile===0&&pid>0&&uid>=2200&&ticks>0n; + if(!success){if(length!==0||(!prelaunch&&!worker&&!outputFailure&&!cancelled))throw new Error("engine broker turn failed");throw new NativeBrokerTurnFailure(diagnostic);} + return{text:output.subarray(ENGINE_BROKER_NATIVE_RESULT_BYTES).toString("utf8"),workerUid:uid,workerPid:pid,startTicks:ticks,diagnostic}; +} +function field(target:Buffer,offset:number,length:number,value:string):void{if(!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value)||Buffer.byteLength(value)>=length)throw new TypeError("invalid engine broker turn");target.write(value,offset,"utf8");} diff --git a/src/runtime/engineBrokerProtocol.test.ts b/src/runtime/engineBrokerProtocol.test.ts new file mode 100644 index 0000000..1be3d53 --- /dev/null +++ b/src/runtime/engineBrokerProtocol.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { encodeEngineBrokerFrame, EngineBrokerFrameDecoder, parseEngineBrokerRequest, parseEngineBrokerResponse } from "./engineBrokerProtocol.js"; + +const start = { version: "noopolis.daimon.engine-broker.v1", kind: "start_turn", requestId: "request-1", turnId: "turn-1", agentId: "agent-1", wakeId: "wake-1", prompt: "work",mcpEndpoint:"http://127.0.0.1:4567/mcp" } as const; + +test("broker frames survive arbitrary chunking and validate closed requests", () => { + const encoded = encodeEngineBrokerFrame(start); const decoder = new EngineBrokerFrameDecoder(); const values: unknown[] = []; + for (const byte of encoded) values.push(...decoder.push(Uint8Array.of(byte))); decoder.finish(); + assert.deepEqual(parseEngineBrokerRequest(values[0]), start); + assert.throws(() => parseEngineBrokerRequest({ ...start, command: "/bin/sh" }), /invalid broker frame/); + assert.throws(() => parseEngineBrokerRequest({ ...start, prompt: "x".repeat(262_145) }), /invalid broker frame/); +}); + +test("broker response attestation is mandatory and bounded", () => { + const value = { version: start.version, kind: "completed", requestId: "request-1", turnId: "turn-1", text: "done", workerPid: 12, workerUid: 2200, workerStartTime: "12345" } as const; + assert.deepEqual(parseEngineBrokerResponse(value), value); + assert.throws(() => parseEngineBrokerResponse({ ...value, workerUid: 0 }), /invalid broker frame/); + const decoder = new EngineBrokerFrameDecoder(); assert.throws(() => decoder.push(Uint8Array.from([0, 16, 0, 1])), /invalid broker frame/); +}); + +test("broker failure diagnostics are closed and contain no raw worker output",()=>{ + const value={version:start.version,kind:"failed",requestId:"request-1",turnId:"turn-1",code:"engine_failed",diagnostic:{status:"prelaunch_failed",stage:"executable",failureClass:"executable",profileApplied:false,exitCode:-1,termSignal:0,workerPid:0,workerUid:0,startTicks:"0"}} as const; + assert.deepEqual(parseEngineBrokerResponse(value),value); + assert.throws(()=>parseEngineBrokerResponse({...value,diagnostic:{...value.diagnostic,rawOutput:"secret"}}),/invalid broker frame/u); + assert.throws(()=>parseEngineBrokerResponse({...value,diagnostic:{...value.diagnostic,failureClass:"secret"}}),/invalid broker frame/u); +}); diff --git a/src/runtime/engineBrokerProtocol.ts b/src/runtime/engineBrokerProtocol.ts new file mode 100644 index 0000000..f8c79aa --- /dev/null +++ b/src/runtime/engineBrokerProtocol.ts @@ -0,0 +1,103 @@ +const VERSION = "noopolis.daimon.engine-broker.v1" as const; +export const ENGINE_BROKER_MAX_FRAME_BYTES = 1_048_576; +const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; + +export type EngineBrokerRequest = + | Readonly<{ version: typeof VERSION; kind: "health"; requestId: string }> + | Readonly<{ version: typeof VERSION; kind: "start_turn"; requestId: string; turnId: string; agentId: string; wakeId: string; prompt: string; mcpEndpoint: string }> + | Readonly<{ version: typeof VERSION; kind: "cancel_turn"; requestId: string; turnId: string }>; + +export interface EngineBrokerFailureDiagnostic { status:string;stage:string;failureClass:string;profileApplied:boolean;exitCode:number;termSignal:number;workerPid:number;workerUid:number;startTicks:string } + +export type EngineBrokerResponse = + | Readonly<{ version: typeof VERSION; kind: "ready"; requestId: string; brokerUid: 2100; providerProxyPort: 43123; mcpFacadePort: 43124; registrations: number; credentialStale: false; realmLease: true; workerIsolation: true }> + | Readonly<{ version: typeof VERSION; kind: "accepted"; requestId: string; turnId: string }> + | Readonly<{ version: typeof VERSION; kind: "completed"; requestId: string; turnId: string; text: string; workerPid: number; workerUid: number; workerStartTime: string }> + | Readonly<{ version: typeof VERSION; kind: "failed"; requestId: string; turnId: string; code: "auth_stale" | "cancelled" | "engine_failed" | "invalid_request" | "turn_conflict" | "unavailable"; diagnostic?: EngineBrokerFailureDiagnostic }>; + +type JsonRecord = Record; +const record = (value: unknown): JsonRecord => { + if (value === null || typeof value !== "object" || Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) throw new TypeError("invalid broker frame"); + return value as JsonRecord; +}; +const exact = (value: JsonRecord, fields: readonly string[]): void => { + if (Object.keys(value).length !== fields.length || fields.some((field) => !Object.hasOwn(value, field))) throw new TypeError("invalid broker frame"); +}; +const text = (value: unknown, maxBytes: number): string => { + if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value, "utf8") > maxBytes) throw new TypeError("invalid broker frame"); + return value; +}; +const id = (value: unknown): string => { const result = text(value, 128); if (!ID.test(result)) throw new TypeError("invalid broker frame"); return result; }; +const version = (value: unknown): typeof VERSION => { if (value !== VERSION) throw new TypeError("invalid broker frame"); return VERSION; }; + +export function parseEngineBrokerRequest(value: unknown): EngineBrokerRequest { + const input = record(value); version(input.version); + if(input.kind==="health"){exact(input,["version","kind","requestId"]);return {version:VERSION,kind:"health",requestId:id(input.requestId)};} + if (input.kind === "start_turn") { + exact(input, ["version", "kind", "requestId", "turnId", "agentId", "wakeId", "prompt", "mcpEndpoint"]); + const mcpEndpoint=text(input.mcpEndpoint,2048);const url=new URL(mcpEndpoint);if(url.protocol!=="http:"||url.hostname!=="127.0.0.1"||url.pathname!=="/mcp")throw new TypeError("invalid broker frame"); + return { version: VERSION, kind: "start_turn", requestId: id(input.requestId), turnId: id(input.turnId), agentId: id(input.agentId), wakeId: id(input.wakeId), prompt: text(input.prompt, 65_536),mcpEndpoint }; + } + if (input.kind === "cancel_turn") { + exact(input, ["version", "kind", "requestId", "turnId"]); + return { version: VERSION, kind: "cancel_turn", requestId: id(input.requestId), turnId: id(input.turnId) }; + } + throw new TypeError("invalid broker frame"); +} + +export function parseEngineBrokerResponse(value: unknown): EngineBrokerResponse { + const input = record(value); version(input.version); + if(input.kind==="ready"){exact(input,["version","kind","requestId","brokerUid","providerProxyPort","mcpFacadePort","registrations","credentialStale","realmLease","workerIsolation"]);if(input.brokerUid!==2100||input.providerProxyPort!==43123||input.mcpFacadePort!==43124||!Number.isSafeInteger(input.registrations)||(input.registrations as number)<1||input.credentialStale!==false||input.realmLease!==true||input.workerIsolation!==true)throw new TypeError("invalid broker frame");return {version:VERSION,kind:"ready",requestId:id(input.requestId),brokerUid:2100,providerProxyPort:43123,mcpFacadePort:43124,registrations:input.registrations as number,credentialStale:false,realmLease:true,workerIsolation:true};} + if (input.kind === "accepted") { + exact(input, ["version", "kind", "requestId", "turnId"]); + return { version: VERSION, kind: "accepted", requestId: id(input.requestId), turnId: id(input.turnId) }; + } + if (input.kind === "completed") { + exact(input, ["version", "kind", "requestId", "turnId", "text", "workerPid", "workerUid", "workerStartTime"]); + if (!Number.isSafeInteger(input.workerPid) || (input.workerPid as number) < 1 || !Number.isSafeInteger(input.workerUid) || (input.workerUid as number) < 1) throw new TypeError("invalid broker frame"); + return { version: VERSION, kind: "completed", requestId: id(input.requestId), turnId: id(input.turnId), text: text(input.text, 262_144), workerPid: input.workerPid as number, workerUid: input.workerUid as number, workerStartTime: id(input.workerStartTime) }; + } + if (input.kind === "failed") { + exact(input, input.diagnostic === undefined ? ["version", "kind", "requestId", "turnId", "code"] : ["version", "kind", "requestId", "turnId", "code", "diagnostic"]); + const codes = ["auth_stale", "cancelled", "engine_failed", "invalid_request", "turn_conflict", "unavailable"] as const; + if (!codes.includes(input.code as typeof codes[number])) throw new TypeError("invalid broker frame"); + let diagnostic:EngineBrokerFailureDiagnostic|undefined; + if(input.diagnostic!==undefined){const value=record(input.diagnostic);exact(value,["status","stage","failureClass","profileApplied","exitCode","termSignal","workerPid","workerUid","startTicks"]);const status=["prelaunch_failed","worker_failed","output_failed","cancelled"],stage=["peer","request","registration","executable","exec","wait","output","attestation"],failureClass=["peer","protocol","registration","executable","exec","wait","output_limit","cancelled","profile_missing","profile_invalid"];if(!status.includes(value.status as string)||!stage.includes(value.stage as string)||!failureClass.includes(value.failureClass as string)||typeof value.profileApplied!=="boolean"||![value.exitCode,value.termSignal,value.workerPid,value.workerUid].every(Number.isSafeInteger)||typeof value.startTicks!=="string"||!/^(0|[1-9][0-9]*)$/u.test(value.startTicks)||!closedDiagnostic(value))throw new TypeError("invalid broker frame");diagnostic=value as unknown as EngineBrokerFailureDiagnostic;} + return { version: VERSION, kind: "failed", requestId: id(input.requestId), turnId: id(input.turnId), code: input.code as typeof codes[number],...(diagnostic?{diagnostic}:{}) }; + } + throw new TypeError("invalid broker frame"); +} + +function closedDiagnostic(value:JsonRecord):boolean{ + if(value.profileApplied!==false||(value.workerPid as number)<0||(value.workerUid as number)<0)return false; + const noWorker=value.workerPid===0&&value.workerUid===0&&value.startTicks==="0"; + const worker=(value.workerPid as number)>0&&(value.workerUid as number)>=2200&&value.startTicks!=="0"; + if(value.status==="prelaunch_failed")return noWorker&&({peer:"peer",request:"protocol",registration:"registration",executable:"executable",exec:"exec"} as Record)[value.stage as string]===value.failureClass; + if(value.status==="worker_failed"&&value.stage==="attestation")return worker&&(value.failureClass==="profile_missing"||value.failureClass==="profile_invalid"); + if(value.status==="worker_failed")return worker&&value.stage==="wait"&&(value.failureClass==="exec"||value.failureClass==="wait"); + if(value.status==="output_failed")return worker&&value.stage==="output"&&value.failureClass==="output_limit"; + return value.status==="cancelled"&&worker&&value.stage==="wait"&&value.failureClass==="cancelled"; +} + +export function encodeEngineBrokerFrame(value: EngineBrokerRequest | EngineBrokerResponse): Buffer { + const body = Buffer.from(JSON.stringify(value), "utf8"); + if (body.length > ENGINE_BROKER_MAX_FRAME_BYTES) throw new TypeError("invalid broker frame"); + const frame = Buffer.allocUnsafe(body.length + 4); frame.writeUInt32BE(body.length); body.copy(frame, 4); return frame; +} + +export class EngineBrokerFrameDecoder { + private buffered = Buffer.alloc(0); + push(chunk: Uint8Array): unknown[] { + this.buffered = Buffer.concat([this.buffered, chunk]); + const values: unknown[] = []; + while (this.buffered.length >= 4) { + const length = this.buffered.readUInt32BE(0); + if (length === 0 || length > ENGINE_BROKER_MAX_FRAME_BYTES) throw new TypeError("invalid broker frame"); + if (this.buffered.length < length + 4) break; + const body = this.buffered.subarray(4, length + 4); this.buffered = this.buffered.subarray(length + 4); + try { values.push(JSON.parse(body.toString("utf8"))); } catch { throw new TypeError("invalid broker frame"); } + } + return values; + } + finish(): void { if (this.buffered.length !== 0) throw new TypeError("incomplete broker frame"); } +} diff --git a/src/runtime/native/AGENTS.md b/src/runtime/native/AGENTS.md new file mode 100644 index 0000000..f8e468b --- /dev/null +++ b/src/runtime/native/AGENTS.md @@ -0,0 +1,13 @@ +# Engine broker native boundary + +This folder owns the Linux-only, root-owned process launcher used by the +engine broker. It authenticates the organization runtime with `SO_PEERCRED`, +selects an immutable root-owned registration by opaque slot, and drops a +provider worker to its registered identity. It never owns provider credentials. + +The wire ABI is fixed-width and versioned. Caller-controlled executable paths, +arguments, environment, endpoints, identities, and filesystem paths are +forbidden. Prompt and scoped capability bytes cross as inherited sealed file +descriptors, not protocol strings. Workers must start in a private process +group with no-new-privileges, no capabilities, no core dump, and a parent-death +signal. Unsupported platforms fail closed. diff --git a/src/runtime/native/CLAUDE.md b/src/runtime/native/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/runtime/native/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/runtime/native/Dockerfile b/src/runtime/native/Dockerfile new file mode 100644 index 0000000..67f059c --- /dev/null +++ b/src/runtime/native/Dockerfile @@ -0,0 +1,4 @@ +FROM gcc:14-bookworm@sha256:5e927c284bf55a7dc796262e311a0703344f62f41f5621eb56843111b1d37e15 +COPY engineBrokerLauncher.c engineBrokerLauncher.h engineBrokerLauncherCore.inc engineBrokerLauncherServer.inc engineBrokerLauncherModes.inc engineBrokerLauncherMain.inc /src/ +RUN gcc -std=c17 -Wall -Wextra -Werror -Wno-deprecated-declarations -O2 -fPIE -pie -Wl,-z,relro,-z,now \ + -o /daimon-engine-broker /src/engineBrokerLauncher.c -Wl,-Bstatic -lcrypto -Wl,-Bdynamic && strip /daimon-engine-broker diff --git a/src/runtime/native/Dockerfile.integration b/src/runtime/native/Dockerfile.integration new file mode 100644 index 0000000..c2dcc71 --- /dev/null +++ b/src/runtime/native/Dockerfile.integration @@ -0,0 +1,10 @@ +FROM gcc:14-bookworm@sha256:5e927c284bf55a7dc796262e311a0703344f62f41f5621eb56843111b1d37e15 +COPY . /src/ +RUN mkdir -p /opt/daimon/bin \ + && gcc -std=c17 -Wall -Wextra -Werror -Wno-deprecated-declarations -O2 -o /opt/daimon/bin/daimon-engine-broker /src/engineBrokerLauncher.c -Wl,-Bstatic -lcrypto -Wl,-Bdynamic \ + && gcc -std=c17 -Wall -Wextra -Werror -O2 -o /usr/local/bin/grok /src/fixtureWorker.c \ + && gcc -std=c17 -Wall -Wextra -Werror -Wno-deprecated-declarations -O2 -o /integration /src/engineBrokerLauncher.integration.c -Wl,-Bstatic -lcrypto -Wl,-Bdynamic \ + && groupadd -g 2000 daimon-org && useradd -u 2000 -g 2000 daimon-org \ + && groupadd -g 2100 daimon-broker && useradd -u 2100 -g 2100 daimon-broker \ + && groupadd -g 2200 daimon-worker && useradd -u 2200 -g 2200 daimon-worker +CMD ["/integration"] diff --git a/src/runtime/native/artifacts/daimon-engine-broker-arm64 b/src/runtime/native/artifacts/daimon-engine-broker-arm64 new file mode 100755 index 0000000000000000000000000000000000000000..f028a3ca832c3adb81418ca17d8b062aed9f056f GIT binary patch literal 67456 zcmeHw3tUyzw)fm?ZD6}MBDhfyF*axlC_Ykrq~+cl4P4?2Ip><4FCH6I0(o0LDz->k zY2{9QWce&l!i{_Drnk6t3$@g;V@hdio!X$KW#_1A0*dYLKh|0k79{=7`Odk&@Avz@ zz4|TY8e@(*=9puS$DDI5cV&~4?-O)7#xIe*#R#e!s9>zTLPKL^V_7$5Wnruj^F_TL zH>c}dHmj_8X#g!nYP)o`9WMh_`FY95N9^cMRqH#K7Sz#Yk^d^R^f>d|(kyCs zIajIZTCc29Its+pZOi}ymN-uA#df}zoJsXynlQ}FeD=sf5f6?N& zjErK(g3+UfmJ|#fp^Sz26K&%3-^o4EeEZ2y%AW)jQ*qyg`%VQV|5dwVcK{lQI{-J) zT5;1g7WWkSiLs$5sh=R+5x8S;kHgI`3m)#lJpi|an=U>-3dC3}e?S?HyN3!-Qp=Gj zC*vN5+l(8-lgAN>@?P8%aMKl~6l@I2B=vbb$`ITp6~0?7hoihteIA8!BJMa9{+}vH z|DCTe72)5xLIZcxJZm4j0~n+t$VZ3Z?(CQS)%sAi?5&nm^uj$Dw~+u`J#mx$#^WB1 z+m5>*?r_|FaNBSv;ts}57ugoSWyIg{N5qU+X<|BEA|nY^hgIw9(Fw`c`7(5YpV9^X z<1X-ZV0=6NW4fSsbb&91*p|*h0{)jS=udaiZxHz0PM)W_pkD(1QMc*$Ko|XzFWrv+ zlU>li(*?ax7jpLNg3p{T_<;Pj@?7o$KfDY66o+m%F7l1r;RCwh^J*9Lhq}Q3u?s#$ zUEs;zZYR&6F6b%l-HyJd3wlahx1)cy3w%=-_-DJY=Lp~_-gmyPbRmzY3w)n0>}OjS z^rySPzX?HZH(qNO_;r_$^)FU+V(@Oc(N`bb)`U3q0kH z+v&~R1^uU8&|mBVzoiTN5xU^-?gHP?1^-Q5&_{Mb-`WMfsS7@)F6{HoF6cLO!Jn1n z7Z&H_m(F8(C8ZgqtR%a%q`;9|P*}H|7%G`Nai`3QJgF0r7-j;0J8s{G2Q{ub>zt z=s7!|%?GX!IP{T~E%%Cwe5xVYvP+0QYf<4{G9jLevmFm(fCZ1_vqcLtN^)6R_F{-% zP}tUD5vag5W6|P=Gtl*dMWtCpy)Yv$ufRbg$jNk&GKx#n7G~t+%N!S$%!j_RGh{gm ziwhhw>%5$jQm|c^y^w}hlAWJLbZKdh#TjYya`H3sa+YSZTqukBl^^>UNKB?LOqGA%8;xH!K63U_44x__iNrxY`W z`NT+AMrJ{ADW*O<8}lF=0Ci043Udmx??UtT`JTThqd1GvxM>|fn%#bNpO`$wo{)Ce z&=F%8e;hfo{c#jao02*$Ei1b?dp_x-OXQEkguvWO(DH4A*`7$ zq3u5rPr5d!9pCknz5}%mtJAJ&e_hkLFNGVeIdyD2e3SCM1@}hR1}s~B*eu}a(qU&i zLaitGO}Spr{-EOa$gs%ftFY2uXlu{+qoTSIa#NthHB;Spa(p0dYH-czgkPi5AEv~I zi_@ReDFV7X;V-E83!U)t!W-=`cfz|>dIPkn<2XszGb@xPx}#m?bhJ^I1=US z8oYLI`L+hH-9y!B@O)oL><(-2+Wpuu4W9N~{5qw<(;k^$ZVg_m=L;G<*)G2>Yw+52 zRkH@)Ay5C(u4md0^UI*Y^F12X1#0lL=i^tX2Cv;0S~Pg=7A;zX=le;OPv9UzaubyEqbMvj$IR zKy8;Ae<;q-*$ux88a$n0@GDS*r*jQ{g=+9)I1;5rgQv4Deno5W+B2@<8azK=qBdhS z`0<z(@iiUo-1}|yw5f<^pt!3|5-4dDW z2a#3YTDEU@otSo#lUzpKWyX#L?Z2-jP#Ep92oWc#4Zr2OgQ$yA>SXw1z;+FM6mX^n zt_NJMflmV7se#V`-mihb2HdED8vzSrWLf0?z5^VkfqwvO*T8=ToT-6-0$i?vuLItx zfo}lbuYrF7+^B(tMD#zly?OL> z3a~J)z5juLqcm_VV7mq$3OG{(-vzi_1CIv0Qv=@(c)td|7jUBnwgVRKZtwp-z)>1l z0&Lg7(*b8{;1s~+8u<5scWU6dfcIt)WG$C%Qf&xz&jO~$DO~mjL@?g9?^LG)-oo?u&c9mRacJ) zuAd(es^Xb5rL4E}o}V4MC7%APCzSodYRZ1m)s*djVnT$072#z{>NSzYf=AK5ZY=f6 zFP@FgZY&=8@AbVxe^(^hyT#yGoo+jhr=2M;_q4WLKOnf?d)!*%=*e0hf1K5<@pv{8 zk2=5RF>bL}^(TPcp#3At2H@SIx%#T8i#-V5f&ok7Q$l|F@x$kluKRHus zsI8#jbfcXqK7i9@7%~XXl>bH>w~p$~vCpE8VleR%b=y-QACCH+^>cu~ihiA9^XRAW ztYg;NiGi%B?iWvLlS8a($<+fm2}6T8Fp>0Y`eb5Wc%J1?#Wi|AK5-FBawMSn77%Bx4J3Q5YA=#hA0_a%u;f zJdY=}-s1@&n#-X16wi@5XNm>13qVtM!xLXm{4g)+H$31K9IKW6Eb6H3QeRd{gt#`Q-3@%TZ zvFaPLl>}i)gvB-C8`#P>u$6CME4koX*X)Up1W&R99qfT*?G9N<*X}0I7_6mUugn|* zJqA~ktP<+BEw$D(g7@;DY|U=af9>nMr)h=Wl?ML%e$qAf1I^E}jr6X*7kw!95d!-# z$EE;I_o2BCt`=^{I!w8fbfZ^rWFL=##wx?+Se@RP5>Ij}HWlj&+J+T^%ZYw_Nr@0y|U_N0xooSc|{ZfCoJfm{u zx$G7DJz$c38I`b$CPzZmHq@O5jB!?D^;R1(hZR|29}fcGfO+lQ?*(f6gD1Wc@@*4( z=Qf~k)qd)0P{&ZGZ9{XyU_0bbmhV0Bd!3%LjeP zd=69ncfanV4*K1PI@#Bu!=aFW$G4tTMNa4uW4hgVs6WzUHO8@BP{!YImTcPG!EYZ@ zd0TYNqh&hytQ&2JZV<_&>T@dk{pJ?tT}M6{9BVX?DOdQ;xIWd_MeoCE)+ z1jymzge`7kc$+!5#Y-$SyH9z64%_pHYl-0(-DAso@|RCV|)>R4NyS*{}<)P5D} zEbv>Uol?i!K_?rn$N>}etVctCgUbTH^Gs#8MzM)8*w@9l{Zeqeq>Hu4_7GYPz4Wvp z-abyUuy$LjQ*Egd_~&64O&ITW%u5)_54&V&zf{jgwZh(7AbVYab%|TY#zg4*730&H zRI48gYU;^cC!?6_ZnUBNX~A4v0WXihYTaU=SQ<+y`2P%C17Nopj=ab!8xeQN<{)FL zmbS5g)h+;iV}Z3cP0vQu!KSF+vuNK68CoFA*Lb#zECpkNp2K79?D5aaa--g!OXEWy zg#Q;jQ@ax+i)c9RMl5DOCbo1~6JQUMQO;ZJ+jS${{2?i7VwRbmkG{#TB|hmWi3~Luj^(QQvx}E?yqx|2tKas{Y5!9{}px1 zWgVES$zGaP__%hc@Cv{>_%LEcHQ7spihm5x87h7$o^jr?)=_71oqdzFoW*$N-C?b1 zc-hC5)!SB6)_QMMBi16B0rnaz+Nb*_)C>$~H3NcmHBtUTjYTikzz4CW38`+x8pa74 zAL3=l z9Km$C89pr430t)Iu+j3IG$&$x;!L6Z-PD)nhSjcz&Rhmo$@xLowHH-Al6=e2K7u{= z)5~awbEmr^nHU=xSRNL^9{nkb^}Wj$xIAn*bf3vi`nGY{>wR z%c8EDVP8*tI@3oob|k8`>@%DH^3{>8wsreJ--x-iwwzEtmP{D zOSfCcSrJ3P^L6G-HtT8N@r*u0TchzKtzwVdM9<*cm3i{Q;ouBFK zf^s9BUo_nGY}_fbJ-3$qciUdznkZD8p*!+1T5Da$SVgQ?w+ZHpRftFBg89fk!Mqyl z@K~~+5`nEozP{p*{7Gv^hImSIeQVhPSvD0X+a$+5IMCF3E-Scjguq85ksjNHR!+4Z_vk>RYT9d}w5D}n&26A!bZ z3n-Tl*tdBHJ}-)3;>9GybJCq4e^P{e3t4Zczgx@Rtx9+W@k;l~Po$%Cle1plBfZRA z*H$oBBVq>DE!Ahuti|IIsyq>_tbor0XkJc`97&ohSM{(N_Vl$!7nHUoj zo5#@HU5C9OCdQI}FzAg;-iw)Ic^qF3dD3;wZ_yg95pgzhIo6P9Uq|aCtf@(VTH7JI zJ!pgVcu(4U(ENK=1RNJjE9yN{t;fq*1?3m)TiW-s*Bk-zo$x4s?`+!3G-4icGZMO~?AT5$o#=R*{kbnaUv3VXUzlv6mqm z{0KDGR!=;w)o*%qt_G~(qA;Ek@Ur$IKeX4;Nm1eYCa`^O)}x<1g_@(b*HqGkDn{uZIxAM}>)jP{82t^Ivn`ZDXA zw01t*UvRC0eSh$H`kPm`8LE6lXZ=9XY%60`P5t%IWjNOCVesuIQ@&k=Sh$+qI`I8h}6dh^_V<*1X#+tmsLRrJh}8t38{`YRjTg5BS>R!{@6j1ZH^^Vq+&gdgaogOnuy=ZfErrnBe5u0ZM?^OqFg;VOA-W-ew}rBzNSpyVg5eJs>){r} zm%h*`crT=9GoJqtRn(21H(FbOzp_2lx^y{wxl#0i-}I8_=?2!O@a1^&8Jaipn_bYU zwaK%W=8|+kkYz&gMeO!b!)8OCx{wq|#U_;Iqi(v$jF^ znVTjpU2sj2uL1IImp|3k#nxR#+<~0VtDe25Xnf!ic@=c;TU*>edG_+LJ@Zrh*kb4z zZQNHpd)yI_tG{Aju+mXag9oiUD6YwCpa@pmP{LdTAv3LuUTUG(_@8a-A)G~4(-}qs za{mh$KjeyckHp#*d+rE}Ro>S#j9JP3AIrJlJy_A}H1btuzGy&6`&%3G-6ZgE{^A+K z3=use#^TH(WLp^R33>d>_N;gw-<+(@_IQ56cM8ETM<3WM3T(_otV5AcQ}xU`jv*G5 zWBs#2UH>>V>mS6t)Uv~@XbQ@0&AK8hN|KM(TQoOR`iCQbI))t4=FO`P(M98=T-k&* zAmvutA5k8qGmjSNfpYD;&<~A;;?jR2mN^Y*2yziC@i=gW>aBX2~sPP}p$F=L()YgE8)(YN})&=>YO zqt0MYy-f(acmVMMK?AwQ7xjJQd=o?b3$Xvo6)p%t?fqR0CH`fE>)KiDm9ci6 z6l|?J7vfVTTC7X-cqV?fW7g(#8^x-h6L7w>jxA~U%%}NWGwo4>tD~^*rgvqvYCq@@aWn<+;A%u%_pCyk5QAcx#hx)_FZKv0ow3wk zhJNaBZnP9U820IuN0#|*3)}0vEzBLUZwcmLOv5nS5;_+;3%rB%iroj?&w?1c2=7j) z-v%)ZcGa6=Y)S?Ad?PT~?&?}Sqq8)8v6e|44;nXeStQoayU=%;-Fn3>u#|>X+rkhd z9&*BNuVc*kTp^g&{!~wUq5asmEtmIgp|K;ulj1(@+30*y7s`rl@HaLg=`&{tt9=XS z=r49-qcFadI^2W7t6`$GCBZ;Cv(`e+`gq_^qklKXhIt>|A28=nI=3_Pk+UVNHdO5^ z9kQ6~)@?ppVyz{ax*^|qkW+sa#n^PT9fRBH$7(%;bxXD#@NMN|%tIe!}USsO7aa&eP?3ex{}MQ_5|yMHg&QUMuf~KR83II442b7i&A^bCJst?@qpD zRmVo-I)gEgFOqz+Y>+piZt|^lkWn5t^h@gu0r_<~?JNDj6Zw_&&i9ndfIq47iFkYR zEvJV6e&GFkuaaZO3fKtwLOIrPJl4zceMOK<-qRx9Q#@1l`o6AU=6wDv&Y=^abCU61 z%nz-9eBpon)HP1Ho(E)^yjQe_GQh>k#Z6 zp@)eWZ>hYu36333Hiz|uRcIdlu#D>+JD6~=V=LfjJbT*qIw7$G0Mq=DPPBU+9$&&y z|5xaY)_31Rc8V1PVBaFvyEJyzdqw?w7{e3L;kO>4<=X^{s~K@!usBm%UXF6zI1u7` zzqMPHd}fa^3v73B(@mkplcsO+3|o264Tpcrx%vJrExGBpMM?WvEj@J`uq>@mG_Qi>FkP*{M|9BHr0TbOl$A%Sm&zyo-MQ| z>W;Hrb>H&>;5vW6>b_?^)f24ldt6j+0*oBL;g|Gvk=RdPL%e(gF+W|#_m$~=&2|BhmC4BA26Lfe_*l+ET3Co->Ot@q1ClelZ zZl+I`Ey_x<_59x8N|b7BI4(# z#w*=OZ*R_#_lnE0o@&U2f4?=S7P`&V5sdc&<=};NasbH-tA>6vssT74dc zlE#pOcAO4p5+QEC2j%vWknw>8>~G;qZrTH5?r`osssVFLa2eV$?4`HSoM3Kr7JCrs zomuX5r9sytQQt(d0`H4R&tCxE9)MgAJzLauaAW}N6?(Qp&ldSpU?J9teWj(FV%ykb zrrtc@FZYK}t19c@_gR!zpgWQw0PhzW>`P#@b|PFz>{I9m??SLvWLG}H7t>( zb2P@$Xyc|fh$DQ><;Grv=P@Rv`ZCV5j{t_c~|&POO!Yi((149QH`-Vw*3mXJJd|Ljpa{52~sD zD#;F9B-Vlxb32{|Z1TZ524wyLG@e!1TY?Y8>u+Iu$Rk+)SZmM9dpdLMcUZ%GyINP< zx(;-ou@>@8ZZCKb8bbOy1wI_Nm2l|)GvH_($A3c(-UjbYLk|509levHwadfA6VH5p z?!$OF5BQke?_SUltp#&9lyuyW#cu)*ak*%FIHUL6@M&iX$!7^?@k>yOk6PEsYhLG_ zE*;JR$hHcArxJ6W;>0uUbDUw#u;tL|#xiI9CE)1%l42S0FB9PZeXuvl9f>oU(CSFU z=gq_;lxdL#5Wg+y$t&sTAxH>eKHa6?CJal^3~fR|66EF>yCyQ ztcCQruQ%q|q1eHbumfvv%>MyF{`SCAW~v$t`a0mKJ&^_& z(|A}Aoi$R7cc#1v*cpg3V8lg|i*o~;@zZr9qi+@{|njXMrQSSPneD=}|@F~G}4^0&7&<{OF zqy1IDjNap<4|M71J&t7{Vn$FEzNbItj~n~}d26)mpflQqJ^3-Lt&i<;N4qLmjB7UHHqP&k!RC%R>h5&e z-FLc{-|4KMix_{_6j*guq&*osW>1fC{bgj&s*8ioRp0jyuDTJ?tLnRu-c{$yLaGk^ z?Ab_k)Q;>_rgz4;_5rUG%j?g|v4`GOgO5AL^$zf47q^QCKVWQaW5|eg8TQ4tk0E`q z>)`gmF4FrsQN%lZy^DDIu<{i9_JJ--e`oy<1^!jdgSuB;hzO|quB=DZ9O#q!A~_;D z#U9y~!Rs~T6OU@!H2=E+Q{8er?^2&L@Vrxfw&8i3`n&_rFX1@?XZv*f`~UiE;u_8u z`2J3PCncX#Y=m5mZTp&DvA3WX{=TC-VsBXhlXG{|130hw<^7&b#=5x;zHAod?HJe+ z#?@0^8=}wKtqrNqO}M#iR0<;BNA-@~q3I8?w5`8uD|GTYvJb0B@ffW|#HacB7s77f zwzswK6?0K|5z)8P^^RSop__&^GN)@2LUZYx53PLK{R_jm+*qsPow~dR zr1)xqz7N1ZXxtdTd~QWL=X)EzE+$&qcag2Dd!`vtPMkYk*nbbb1W7(DTekpD<6UlFmW*Le^xM`FlT^1=4YH~;jAn6puqN~ zwX!R_vF6+O5cd4=McRKjah6W=RfLRQxa ztfo%DcbI7ZBx2sWo1Q&QhW~eWvV{f~!xXZTfdhpWlnUHvHho>>Hjv*Fi(F z|1I*~4_TbDoh&eAF;Idw;~(ton$tj>d?w=|4q`brN5LHFpKpm!u~-4&cm*-c{dDXWDOAvWl6@ zd7sjch&}}Lp+g@I^pS}^GSoiG1pUQ*D*p@6XIA;&-;T}=Xlw#(hx9lXIwW5w-H{&W zLPzu-k@Wm3Xn9Pi)I1aJQonznPUnkO_xKZ3vZQDkW`^hoWjIN)5iu~H#%>ukI=CzZqd0}+m_nn zEOYFgxGR{C~~QA>4RU2tyexT>;;@kc;Hq z1-?XgENp{$9B_y4J}F%hdK=B+XyZ81N0xQA*cUtxOG&29pb0X~8gxb)r^>ol=mS~% zNRawu2FVU?Ht28$WX4BJme=fZt1RFmSMnPwOvaDvY4|sko z8BLo&6J!b+bV_Ox6umAJLm+D(X%=`k35u+SZgq2GqW47;ZDHRR%_o7+9si}|68ak7 z6~;qn23gitAryR%NEeM8K@()UGVlxOB>0kE@y-Eds8j^LCm|Q0{6zZ1xB)alrsD%oN_$mV$BDflYl!3m&%LUw5_k?C zzAu_+hxdKKTnT*K_|wuXv5)alah$PIFv_x$4F(SVlT>3|51Js;8v{>Bm8z_vVlZUw zExiH0m8z_{;7fGeUIRMVt4WqsvDc&0v%h7p;K}WE4S2?l?P#x;I@qhfEURL#pGpP4 zWv}4N?X?Dcwf0)q!Ct$|vMTmkCs}{XUf~1WUXOz3Fs9h+cR0u8Hv7j8HX9(ztJv&8 zY2a_zEcq?B*-PL}HhW%VwcKu3b+FqWvdoIz9+Lci!*0oMx!p4OLZrbOLw0)sXVBbs z7j&>)Cd;hY?qTV|Z`f`Sbj)ox61>0bVXYb{suvxtVOaTDD!m6gc#>6f@-miDH{<*Yi$GQassn zknDSgY5Sy@?vqse?k&x_|Ab_Su_1T(o5<4=E{e_e}<)A!n z*+=GmA+3q0Sgwp)nsz|S?S3C%ib27W>;5k#DJDVM#R5ze=efV^VkSHXnRY>jA26qX z!F=*q|6}+nGsXJVlPb&<@2AQB;)A|VN^7PZlE*Fk(!7&WO+3YXW!$;bK9JUQ$Hx(J z4(KbrasL@9H)fJ_ko7Q8KHz?J5OY9n4nl^DnDf7Ce5-rEC(V=(UYqoUnR3EV*`IvT z_X(+H%3*ojvaiiMAsvmUyr7JG&9o1tn(j$zo(PkU-+x+K6Ej&lkDqO$oWcF>Jmx_5 zJIL@O=KGSy|AzK^%1n9VPm@-fDR=acea;Vke=Z%J@{v4l*&pY9E?tVJ{Gp7yX4*mN zX!j{DC0gl?T~b-yQJovP$_867t+y~L@7omV19dJ z&ego5>w)JWQw(JIE9SdF~k@$cpR*2w;C znf#RO-#cahHlpv-Qe@I6^0>Kw+efwgH%tCViu6xz_wP9;Bo;kI%GK#R_;)Vmfc!fb zGPp6{XEpx4v)?naf7>QME&KNh*}qNb`;=r)`jb3vIlsZb9i!U)J2LrW$?iX`-M{C2 zE=5L5(i)vl2mfA!IUxUD0~s1H-{&;`eW2g7vVUhzUM>4~mh9jD==%%Fk#tlZH}~&~ zQSJV1Pd*|!{HM43_na>!d-PPPM(5kXziTiDG7eH3#*{(TfOoW*== zbEi+ZQ}*vyCO;$lw~{Xd(Dw;xS5lokZtmX)N45KRMe?7dUH;Z~|DJPNs)(K@UD6pk z`1d8u0r~eO$Z!txt)Zq zt>k1K`aUBWrW}yR&HXzjzTLmiC)Y`a?g{Pweg8@6e6&@vBW`Q`+m75x{%wa0l*9jT zyL|p))=2qTTG;zx%@2lm`Xu3b&F1j*(>OwEAmxeQ*bJa z2^IY?9)qd(A9OKuMVeEiiN3!L%@Sh5^2C@1mnZd2`gR5H+VT-obmj zTgwjbejj_pe{}!3Y1q?t*Q7B;pWdw7yX(un-#@Vb_v5YvUAh02@`=x%crg55H&y$c z(mk-E=X+0#7;xY*|KFAVulI_7dE;y6tG-{oSbF8fefx4=Jhw9Oy-NqT?CbH#4*vlK zL%xrzt|*nhs(SqoXP#_%IAzwon-(4%vHS2K@%2Mx_rHJD&dZ_FLZk0cG+`|j(b-t-S2Csm)CKI7w;9=KXC zbirTCpDQhJ>{=YW_;7_mIJD-5=e@NT^Jc!%^m5C4KP@ggh;w~5JIQue{Af!iegiAl zQd*pmUxJ?wTWG-_i!Ei^!()e75>oD;nK~oUHf^>gbBSd_Mj?K@%St~6R#K8Tw4iwY zz3gw<>)(!s|MmmhZB2gpswZ%O<(p)jF&r;k{7rI&!IHlV_d(p}arX@0K7C~Pj_F0H z3taii^jC1UA^>Nx20mz;8TjA_>3$DVyAdmQP5-d1E(mo&4}P1je^6=n$LZg-)hX>( zru(SvUY)+Gtxjq8YPzr5?*IM%w7TSaRB(zeiWXa;_diZ|)QQKnv?Er&I$cZ0D*j`7 zWW}4)cV6-AWxHAOEk9c`_ftOK9-PGvKj#Uga~n(HH_4H>qj2}ftzY_0vJY-w+w}-@`!TQ7Jn%AP~7{IL(|Ve;4c8Hecx851pH{>9)!*t2vumFPnsT%+p| zn~U|{O8vNkf*}W_C-nCeP#ae9>h#<`tIaD@#}zDOdg;=v1JWw}P}G^^HSSSecjK<` zf%%uZJ!d|Mys;{EXaRDxROmabAO`JeUG^jM18h1wA$I^V?IJph=$0~MBpRNrfymu7v0wYA9t1Lqo5su@;ui02K4zF(;JI? zL-Rjn1B|PKKS28c=!?+G(|9~->l`&Bt4&=BR)D2j= zEqQ=N$fv!`dCb`wCK~^p^*7G9u>9QM18CRXWNw?Y1M-|*8gfXUGinp5nX|tz1Nu57 z&l&BBehg8@#OF-H+D@4>gFI)u(4O{GdCVx!*>fz`IJMWu@|+#SKCgYwK9)Wma!{T# zYD43S#GD;ve&`Q)%o*(if5dlf+Bu8C8mdO;C(l_0+U-JL$+~XFNa(A8#TW;2(*kr$&^#S|^W9r{NXXlxS@%YGd zHVHn}+0Xpod)&`Pw$0hE{mibJvtRkyppJ9)&-63%eb)ih+W^3~t&vBIBw7HGvHJ;OWJ|i09$McvrhY>IC zSK8dA&0C6mO1>f*;zfCi=O}G{BA=nWq|Hg%e5Cb}zb6lAeS_97l!wSSNQab%6ut)} z%0IMzA$pRN`vb*LZQh}AlP?_A84%~&a}CckJjditQ}PSXEj+Jia|-d|`9zybw0T64 zPstxdL$XocxHL=28QOe7Hhfg)E1%=?9HGq*3NIx${5^R=YlpP{`z<@9_1~}UaHnF2 zY99Ef*r6KZ|0#B;iQoUQ9d;3?wRTAB=ijnJT0j5V4qsO6P>sv~6gyO7?mxv2HSzQx zwZq%ROj`S!hFfDO?sXq)LutY~$R9X+46V1w@4Amoh@mwp#TtsQVjzp5^)u}&d_r|G z6yN+TLJX~ix|*UJ(~pl98X4S+H2`PaOh&)jP*Q zL0rkA%#8Wj`K2X9r>H0nzs62P!{q)-vh(H*o0m0g zq(YCM7cVGQG!_?^l~Y2$#U1xZc4k~jHvNx#h<99^AAHZw zSeTPPKW-tkgkDSH9xB04yvN1i$K$i}_&g~(kK?1m?}^TsHu;`AOITvcjHziVFl=ah z{UqCzX)~s$O-q=WmSCG{vrkE$l6pTiP%pKw8JGEr3Q0qtIs~a6OWD# z`E%j$arXx_jWE@Df_lwa@al6dS+C5v@!tIJx;^y7zs-NA_-g)yyMLast1Km8)|v%A zFaGQ46%l>@?@C_j_O-Io>M5(QC=oI zu0W+=x}_;hzxWPaU;hXh7Yh7qwfKQMs<-*wL3BfKHv>1EaJUF(#T^WN(LYF`%Yr)< z_XOaQP%>M9Ws82RXkRBx|N76L1rUnbk-z7G2E&oBVEkQ!dogfSk9L7ugsnR2{}tSY zz&}cOd%*B5zFU3l>-3dk!g^tYZl>|;Kc6`H+2;YwW}3twWjVeA`5v@;lG?S)QK@?n zl7M~y;W1wWFrRA`8tWFrR=>&Xd@FsXf~~@okqptGTSN0sbbq4^q_1?eJJ!y>UG_;N z`y7qzicZL$M!}9I(frr~w)@!wwja_bY`K=S^=guR-A_rCSCSIeUryR^DQWV?zb0+^ zOVSHJCT+f$BqSM6pZVexDdNvx+X9?*dK)RE%HU)QrTD7j5ZG-t`6n*+Km zqQf-_cRFypfqMnxg5KOV+m@FMTVFKT*KIabzFmN8Vod%C#2VAx&XkZmG z+X?Xa1MbV<^EvnofM1b*b-0)7ZCfg>Ti03b>z=n(x~vK7*IG9`XHD4ntaZ~f)`S<< zSU0b>CcOBx^`)n*!t=&2ziO8cnszFh2D?{qlOJz_|L&mxW)FD1XwsGq0bADx*f*{X z*z{b0eO+Zh<+^}`_0I=va0NVOjQef1BDgjucF$quer#947j+wz9AQt%^{tXR#?YI)ZXkM(gEo%&0S7TnD zHdH=kNLc@*VZ$m+&q~9lCk+46LC%?*R~TNXFl3)OJw+ovSSU@EmP$jAfmq=TvC%Kt zP^liVs~Ubz3BVrU^s#N3xpjtp-So<7_VvjdrV5icP1z{fHYdK2WP54yi}%^SICc8W zmtW->JE9X!iAKfVNqcjtQm`9OeE~(xmWx#4U?&br%=r=hCgBHolF=<)a-2aN#h3MM zxS7BQ16Kze`7Y#>W5Ohg35o0V8^n#mCfzL9y)yOyHpP?~z&`u@#K|vC{rPJtz)FOi zj-rXu&jRrL5&&UacRpTi2_apENfFD43T$AVz;!N-x$QSSn&^i%ntXn7A*WFur z&n)Agzoy<2O*1!)Uq8XN@opi}c=Ckp3%e;(E>tXW@m^jHcs1bFfL8-v4R|%+)qqz6 zUJZCP;MIUv16~byHQ?2NR|8%Rcs1bFfL8-v4R|%+)qqz6UJZCP;MIUv16~byHQ?2N zR|8%Rcs1bFfL8-v4R|%+)qqz6UJZCP;MIUv16~byHQ?2NR|8%Rcs1bFfL8-v4R|%+ z)qqz6UJZCP;MIUv16~byHQ?2NR|8%Rcs1bFfL8-v4R|%+)qqz6UJZCP;MIUv16~by zHQ?2NR|8%Rcs1bFfL8-v4R|%+)qqz6UJZCP;MIUv16~byHQ?2NR|8%Rcs1bFfL8-v z4R|%+)qqz6UJZCP;MIUv16~byHQ?2NR|8%Rcs1bFfL8-v4R|%+)qqz6UJZCP;MIUv z16~byHQ?2NR|8%Rcs1bFfL8-v4g7zt0rwzf_<;kIa*Yb>RIQ8H84Z>tqPYQ5W}Jou{c zDYf3H!f&bVRWcT;;(MtjFGTgB@RlO4+Keq$83(KNerl->n;ljM#PMr;CQNJCw5c3uWGP@f9(N{N3C{+4Dh#@~4WP*;$ZQJTSqW-myaSDdjhJ8eN0+Hy!~ zk(Nz7r1=!+Coi=Xq&WD|npeagFndMBD3#dAvQ( z3%um{q;q@T|6n|j4tRT>H+X4K13HC&$_>1K9=}Jbc;24pAzt#l12XyQtiSQVQcUOV zc^v2E2DKfRpVRR&3H6jCILz||FVofbBtOw``BgOg7d((Id3&C>c**k?@1M8lrL`l-9s#WFpRap($=3_KStoJWV`}?0{Vz}`*Q#Y2Uy!H_I?Mk=r}mdg zp}2UdC*d?#=l<8Bj!1a_&3OtjFO&Ghs66=msUX{e2U>b?G5I<#>83)_Szg|rmsKF6 zr44V-*MW1?_MA@P)^0x>zZVrGB5$9r=9hHdzdA5J4Bn2*^&Z;N_<4K2?kl_nB(Akw zF$&mbznsqj;I-}BHK{0`_oFs%YtP}2@IbtH|AFdE1j<|)b1Q5(IqS^tC~AmgcpHWC cz}xrigy-zECGT5H8m0FCg%*it-j1XHH#`NiQvd(} literal 0 HcmV?d00001 diff --git a/src/runtime/native/artifacts/daimon-engine-broker-arm64.provenance.json b/src/runtime/native/artifacts/daimon-engine-broker-arm64.provenance.json new file mode 100644 index 0000000..85dafe3 --- /dev/null +++ b/src/runtime/native/artifacts/daimon-engine-broker-arm64.provenance.json @@ -0,0 +1 @@ +{"version":"daimon.engine-broker-native-build.v1","architecture":"arm64","target":"linux/arm64","builder_image":"gcc:14-bookworm@sha256:5e927c284bf55a7dc796262e311a0703344f62f41f5621eb56843111b1d37e15","compiler":"gcc-14","source_sha256":"sha256:bdcab1e12dcc531ed8e56f890263ca23a9ee7bac468191dd598e143df4ff8c58","binary_sha256":"sha256:ad44e02c38e6a3207ac4a3d5fd98b6d2e55341ce42dfd2f07204bbe54a7a653d","install_path":"/opt/daimon/bin/daimon-engine-broker"} diff --git a/src/runtime/native/artifacts/daimon-engine-broker-x64 b/src/runtime/native/artifacts/daimon-engine-broker-x64 new file mode 100755 index 0000000000000000000000000000000000000000..3b0b5d1393dc2eb07014ada6a3fe88903bcfa7a8 GIT binary patch literal 55392 zcmeHw4SZD9weOjH5g_KoiZ)uTgN`<|d}I{8L7`?~22Sh%QShVHmIO#38j_gIKx|bK zCqd5*W4XQ7>uv3Q)LO5$eJ$;^id10&2?2@_Q32}*0sJ^aKmrs9K{D_E-)Eo6NhDmX zaNqmA9|z9tv({dF@3q%nd#$ziJ~LV5^G`}}I27wjRKBeca*i-DxiZ7~hI~MhtK=vb z;PY~2sFDIWN#M- z!Yr4}M<+jUImKr@eIaDRR%$ON+FLH^`=k}=Cf-WzL;_rUkMS)mCRDpA;9% zb;@#mQ$@JH?`=k)M9t~M}ay2NPGFRT)~Sb zBYo4SIZ(K))N1cml%rhE^pfIKZjEtz%0n9-$kMwXV81*=C^ z=iD&zhB2e6%12!*SYR=>L7kdB^>!tpMu`w-mACQ4VCeDz6o~@ZeFZE3-{M>lcTY?ow#qneI4$RxZSvU4KowvMxYvpQ{XlfciM+noMj%eb&*lBp z&+$7I{j`4YKkcWUKlMZZc0c%?{ot4MgD>x=-Cy-XKe-?L|LzBm>Q5<$@AZTKRX_aP z)(`#<{osGzPyOHRhrXmA`b+wuAJiYdpL)L25B=DF@SIbp(w~R=q5pY5_^bQT=caz@ zU(ioI!}_5g(+|DT553wCJ?;0Y`n#$hJVUTk(O30DPrH9AdU~2u;b-aXqxe>Lj45_zAys>&)VOUeSpN@-P~ zFrZWw1**zt-&bA{P-f4YUp_~vsGJ=rRjMASnq6L2tQ1!k6_KX0v}Aq>r~{RO@?dGH zQaF2dQAJ>kQarm1)U!&;<}j~v_Pk0ZScVAWJ{Fo=SstvYQYy+(fQ=N1<|^iv%u$NV zD?x&4i^`O_MS+S6-~g5pW^+YVq?!|~xK?E2xw2^X0;?I zC4#JDUNA6+H1i8fOUq{~GiM_1nLQI-tqjbZUszHm>X~0P7yT?MoTHSMR0UAM{G$0} zy{f2e4)bQtoLyZwv$&+Ju(afdMaq38=m5C11ck? zX3i<9ESgIZRL+<(v9!FbXhz|z(jqW8cYb-9l-f*_^pu2*FZeG}d^#j#;(9)l`oSmL z@GP5RgL_K%JPi=}4#ZF~-jMKr2{>7K zPr^$DoTPjr;THv*s2q}T@Ujwwa0$vuz`U$D+gfjkz!dlua(!drBQns9^uZ_mQB z?}NW}xruM>gRhYI9ewa&iErzJe?j7nKKQpJzM~JmP4bi4YPN6jZ^1jZn0UsHjM1&D z&4#zfz=jQP#jz~YVZ$?Sx2{ebo<7yOGB%q}tXqm+ATn)un9%s;wBZ>;TUVA1Z;!pR zZFn;-HcRB#@Rlr?lWW5>rnfH5hG+b5UHLZrU<--#E*qX}1?wuX;Th9g*E}0O-9jR* zu;Eo3zS@S*u;FWLc&=Zp>oFVN?jM)h@LbPW*Hbn;*EiO+%!cQB%(|Yp;jOhG^OoE2 zT<2KV3LD;9$1pEq!*d;JUF&UluCJ`C)rMzmVO=|Hcxzq8yfz!&T8|NL*zjK`LS7v< zJlCMs)oH^!EhJN^=hWw6HhiiLZ}-<3HaypV)|F|)b8To{P8uR;(xz}?3Ixm6q5;!k`^Ab2Of%6hLFM;zCI4^J_I zz&|6*t%hiUfPX@mTMW^B0sk{$ZY@M}1^ftMZYe~w1^h$8+)9W#1zbt^0>T*rzL#)1 zVMV~R2&;rUx`DX*dxW`F5N#9iZG^c+5N#FkErhu>5RC|U0%2|mM3)QrM#9_*h%OWG zwS>6^5M3(ZZxZIxKUyQ;D+zPSAFUAZWrVrZj}{2{D}=ekkLC+_5MeIuqqzc3Aj~Cw zG+V%3zXQyreAFr6Lxj16k7fvXFJUg-ql$oc6XudV+HsQo{~KW^;WhzpCCnvyv{k?x z33F*4jR<%RVJ^v|%LTlWFqh)dWdi;aVQzgzmkRhdgt;V-)(H3+!d!|+D+K&A!d!w! z3k3WV!d!Ys^9B6Rgt_F7<_h=`!dz-cvjzM^!XpSf1zbsZB;gDJ-%FTF>Zl^%S%gOu z?)XfMKVdGRqiq7djWCza(N+Q9LYPbDXhgsh2y>|%T`u4o33G`YT_)gb33F*2T`J&j z66TUPS|i{q33DkNtq|~Kgt-Kc7R1XJF9rM$U7kDjE_cLphv#F;R6kB1sd8>5MbwcoDhtT&^~ zV;_LruYY2!KM{+qrUfwm1GS+7kKSxdMCbkbQT;$TKhd}Wnpz(T&T=1JIR>VIh4zSs zgVR)_Gupw3*6@Q=EqwhoOGI}J19afGj#~&oXG-)N61~YpUntQlC3*wUE6SNy+eD$? z{e90pp6`2p;F-Bry+Sb(1-sRw17r1_MheQ9{Go>w_i6|!W=s=ZSkxY!1jx(>@jKu( zItCbbWEJhADb|qLZ2e6vxBvx1hC{6z2?#xX4n$!at#Jh^X2E0@+-e*1 zYrqdBtv;EWZWRK82{^rpbo(I43xuTmv;8W!NCm3R_2Owr+C49HltE29S zS@XrR=GQ?XGtG8s*o|YTTMF=ZAW-X{!zcJQg&1&I)kUoStrN^Vu+0-1=ek6#UkLJ^ ziMrF63w~JN0~e7XDX<6PdlR_m(;yV`gn)-aw5x!09}R7ZiAiLrg3Cqw+y7*nDBqJE z{|d`a0fm?-Nq|u6L#RLt`(0!FVXte1eqbdXzvvkZ<%l$PZY4-yiw4y`=*pqu1=7*5 zj}6C+^kXdks%rwqKui^Xc>Hf3Qxp`=W?>ChH1+UrwQ!xwG+Qywqg+rskhf0Ly{-&n ztl7W|+KQVy5SrmynW(fnwX5n}>p`hC?oQXnwg&ZHGpvas7c;C31+)gVBj;1MKm0q< z#NlUG(vAA{PxTgKj+DYs)aZvN$YqDKMR#Gq_2Z(?#%A<~if|O=&|fb#N3Ayic~;QO z`t|5ahC2FzVzoTE0zDPw)Zu2QjISRx%fHmC1cM>{fZ4)lM@)(*dQpfvcbXJ`6BNM( zJuQmzGWS(8_pXNI1hlUIPRLb%+O-1x((7C=n#rq_8&RCZkhIs8+kktp*X0ddPGMz) zHpW)LNGrx~MVBp2dj~pEf=)y;VYhtlqZ32pURDC%oKk;Ot$PmS`aP~xkG@|+Z5b1e zdOK1a!7)8RX7DR{;YkjU=!h2TObF=hufb}Q30Q)OdeY0rchG_McQ{_KCdPgQVxdk4 z7#tdS*}b8MGVNt!7$_V3uA!kuNATx)gNCL9K$PmB zmy*l(qqpO^c$9iY9olEO4}a+1pQpd^15Mwpg+A=apCM+Z79QUWDa0=Rw_hpB-|Yo* z;s3_J0<0EJ{u7h2oq>z7yjZ|z%)x=NHuZ`cRt_I0Qqb#y{`yCl4DZ(T4z2Oio3+Ny zM9r~Id!sur1QqyX1*x$%)6ZGu5367Asu|C0CHSq|wb1x0`Kak11=6&J@z($_-of}7 zbHMMq~>9m zd%F8wQnjaXeWW?oYGd20vNU~fUN~?$hVc_^_}`2mL=6EfYU9Fc1)7VQ?tU#{cSHj2 z$XZl*WV)uGV2wr$O;T*PXk(iLK5g}ho52WH3?np5XI3 z@L3uU;AR!iYAUSfHSxJhe6HlDKV0XU172+%T&n3iw9r}ymJ0r{Yl8;? z8yY-2U^=x5_H=A>Fsg;0aNUFMF7@ked7&oe=Z$?Uco2MxZd-l!)o-naShpKV;L;Q7 zjs*Z6Gmdr;Iw_z7h5-mcG{xxEJ8f~aTSRTzN(_@?On?WcuNno;8_-NRKUNFx4m4zL2q#W7t0l>KtQSKFfH_2rrpuE2!&4u}Rmj@w)C-ge*&A2zlCDAa$rTO*s zdEvARX(%p)z0lWap@?H$0{Xo^c+jtJ@k8q9#1NMe`4j( zJ|Xti zpbEVzR4GBNA0f)T>e_(eHD23rLrbFC7`O!U~<^tu>*BRsGAYimOhI!#3j`qZL z436H=hBs;m)v69c1uf0&k-mRyBzTYtq}F`{EKqsWy36rokCG^I4o`;hQ>YZ>&Y{Vu zk9hPo>SIqpjVRaxv5xiYv6b9qz=AIq#*@XIhkp%Kg}nW`-<2VT_;S=^4lywG5mx6^ z>&K!dghpQd5CxVGp|g0l*0{^*2ZHM+R*|I*-1rK2qP+SHRF1F^%P38M!qp~lXsOOs zfWq*PzXl5{m0XsaLz+6*4$yGQv39`{f*6{GyEwyX6S!xG|0waQt3u+ck#bSOd~zY4 zbtr$)KK27#_z_`bY2$^NKxeYm`Xiu2w^!C6_3Pcn-w>YmkW$Bxk-(mVcs-s6*SR#b zqt^cg0P^|DyhDAIYc+4f)2?v@hB9WV_`T<;;N zJA=%Uru471H>ma3AjNR%8{xr)RtilN8fe52N%ah^l&4yMm~$Jw&!ifFYxLOb%3?Vu%Vio1 z+s$&BWZen-L*BF0`jxU^hEN{SO%}~$QPV{3N1W*uCK4F4T&7yjm(?4d5E{TZ!)h|4 zr`(_IgJ*9s7|`DHK~(a(P|*5q|ryPUK8$_d=X{6CGq4 z(dcHrdjnjMegN@;!$?Pd9-QAzz^nC9)*tFjRu}KYlUIHDMbRXdbch>{XyaoD64gi7 zBF_&~@h7JGQNO<3&&h8b!ho{N zxS02o)R*eU{1>Pu@PT!D8rxDm10z25Wg{=_iHWYXmN1?@}>{0Zi4s;0#{fn1XiNVXWXROr-JdCqf;zGL~g<7hd9e(TRS^ z^+%6~74Z#JgwmIRu-wkHDZtX(VJ-1`FxDEi`|ir1vq9iHSONykF)IUI(V z!OBSELevcf7ke1OW@y+pTq45$AeLMR^gb{xZMBKJS!jjWzcBFyz#rRjDfD3{ZNCTX zBfjgT@-n_1yFGXi{b#TA^_V1~4YBsOpr3jSDynaTWppT>+p(YM!7}>8+o`gY!aSfU zg?#M&VA+V}6KtgM;YTn5$y3n-_-yf_nin&g9r5D!>&CGi$QXhCD}XZ@8jKS$8J{3f z2*isDbA4|6jK}=r(5~!4F>^cPJqQJ2eT=q7ec9`p^gMR%!I9$L@O-BF(?;*WT}QpH zsX&ef(x>n94BVvFFXpow+Zu4W!s27@tU*m!re1<=xStuxAHv>YzkDf~1hIl z*O~hupo=a5yWt6r5f1h7E~^GTKQp=?fLPqFz&4~QC;iqW-BYT2D$HiKa=Cdcq_5|v zVRFO1+UK(oJm{;Q`VQM1Lf@&swlx<^)Ypji(2-E}5G9xrs?JmbL&ZQL z4jP8;w0M#@E*hAL-VgRob~eP>(UP>5+)}db)vMF|&@(3oLM3oO><~Jn&rV7nXPJ(|zbCXM>wEK4d0fWTTJF%4}7v*##Eo(uC zW#zuda%jJwbvaQ3r#(Yh&Pndq;_QL@qT?7?=aL>~>=kYe5TPMeTG$6Pyj_?bWP>9G z9B~tQj(z|!A45^m6k@&zX&aKaSF%W*m^sG1yM+Pfe)2pn(*tGZN?Z%SDCmVuIRCJk z)OXguTb*od-z7wnTOR|k5rEcs!YT9qV&-{8-tPhQtfQBavbKpGwhbs(Bo%0vp=(Vh z_N}=h(HbtE045Lr7Rw;9@8j2>buGt905Fgh>EJT4RN>_31~c=pudouzlFQY_3cz9! zq5sac6x75$g_J7K70EJ*dmbtNE5=n2gSqZd>rX(jk7A1)8HggO<{H95l4_wYRbBk9 zz$`H;J*0+y#d0FT5HoMA2hrh= zi3@cj>a52TVmRtSg4Xce47I+Bc@Rq$P-0VxdazGtj7Mz8_M!|7^&KL%(Hae> z-U1IW#M)o4+sN#OK?I1j(bX3h#FbDm$f&Hf%nFK9crWW zvIQ>ra>(8qhj3}nI6(M6gp{z|0piCJS-TMa+d#7TG{~!e;k%KGdBX9?L!e7qNT7qM z)VTQr#ZhkE0N_0Or<(r0)-cr7ei5cBLJ{L}nY|m*TB$h26z0jnbMK=k*m{CHep}Pq zz50Qsz{sE)mZe3XBS{h=+R^CTJ_s z8^w4Z(K1o&O#}!Hk%)%EFXCD5>V0g?ljU%rYmC6NA2B2x%l))pIySMeUC3?H*5JV@ zbf*7?vZ&K*C@g21PQIVeFvFFrzvyaWGlHxz4X*?OR+3luGrzC;`j7ptX>%xuNmPOBKj zScldio&;i6_o%4u;?)32ip6nq9-r0HHH6y$e-AKAa-WYlgXQit+3?D89iS^P%dI61 ztU>Z!vRnbmvB(G(!Sr1Y*d0L&O{|p*9OhnvT;Q6Ct1)rdU;}0yF-s*?XRV-WA&!>V zn6{nMYaHzKJD5`8i@S?4DD10oJ#u);pNYjRU%3fid<1!zWkW?qfA$KzFk-;6BKZT* z(=6qpI(CXe-bEaO!W9@@&nx^6vU&-Y>d0+HZYx{oz>8iWEzVQRn*8u5H2}cEz9PYY zqmTnotN9v*J0iLWkb=0pR*SDaYC&M; zj>IRg&{gA4KnfBxq8QIw5E(R{v>>7Dk6Dn=%O6@$0x3%+)K1THk1>9mS*bAw5B=NK z=oL6!73*QKza^#=-q5oSoq6J1yD!U0sg$~g+V@wmZv?v%KX*R)HZ2}Pta0cC&!$5s z5*NJd1usKA>K0FUa%?;rQWj)c`#CG=G8qtegU_X+D8Z}HFb zd)7JjgUa_n9_Ob|`}^ON{T*YYhbv6;3~vdBY=Y`(&wf7Xq)2%_DHZ|Izjw#i^X^FW zV?ax3xJ9OV;p76`PTbkJb8+W$)9JTR(w51kLn)&R2N3fDFv!ht$JQ4A_vIVR@?gX& zuiMMxSW&I}Ck&?N&T!J(AX3DdRe#$vBTxT3c5~mxR#sHLK+tffD{-<@pX_XyJTJ5r zZw5s#cWl(}bU`D6-_}AWZ&n|D6*YME`{rrkAMu?9`pZ0yVF<4&pcR`Az`bvvTf#1^ z7jr2bNXA^=>klu=@((Psn)4FC>DkNbebJ_%8_=h$at!N28&%V6aWL|P`!LmosiG0Q_C^25oGZ1TV0 z3X^9P)Ef7u7@rP613YnVi{v?)$N{{4-Dv0nUc!kEK`J^G?+W+BQnc`$saj}vr=|}; z)HDjIcXX#Vd^=x8aU9S>pJ0a%=LC<4O$9(Ol?`brnx2+`P%Ra&xKtj9-iWG&T)>Z! z3MF*hH!u1!6ov~FRQeg}{7f*QzU&><8T}TBY5$}paKn(0x>27|uQW_AC6|qR6UMeyMn!9P>#cY@A})MWUg7&w z^;V5PKLNg4ypB%s5c(J+a4?)SDAd*&>V)v~AS*n(x9V?%)}(6sxO6Ps4@a-HR17s+Sj{OH{R)UY6DvjuNCxp`VL0GlT+1aJUU*ysXZ~ds4S@d z6vxz!wOu7;MR|N4*jRhKEKpQxKC5+)qPE%-o?u{}pj=biRa6!z5xHw>kI(i@@tV)U zLb~S~^_lS)lbXuuIQ!B)Y90V;mvtdeRLVy#s*I8K=h}0}S_?8I@qA5?~-gFO0RczW) zC)8(()v^f79$4)O9anzLMTJ2`79-P>D3)-OdVg5lB@&A+tsjnZ?by0b$OE|4 z;6BzBeDQPTWqVT)!%Am(6wCM1rwra7B!M)gZ!>-iLQh#aJac{tl7ugQHi~B>|`*FjuABYwENPv$G z{MgEmclq%lKlbzEC_iEvj^@B8UVw7Prnn(p59A}B99|sO87R|lBgrN%dhX~-CxSfs{c(M z-K3tPkyBQh7ixFlz%$;SckFsJDa@++(>2=gH>QM#{3Q4W7xJmv@Xzam61XF;Wi2!o zdpj+MDlf^4{WW-`eL}D9?%f*{4Czv~?q13`k%vCrc^;dSix zIreDb`AI&!4IM&5c#rsXpT12t_Q|KeWAD>(uVatTvCC}gAeu_UE6YB8vusM=;&H#vmMFV9 zt~8%}^Dv4A?KGL9V?H~y#cXD?m+041e3cpObYnhm|qE(xVWz9~0-y}d)77#<7~N60Hi$a%VHG{@yU zi}T2J`gw$JuAHTLly&-flyw&8(TLN{qY-Cq9%Y|?9%Y}sc{Jv9^JvUjn@2gPpGP@o zZ61v~{X81?<>yg*s|mm|{M?+tauu!RP+S)3`4Y$N@h0F;C;C zgMhuz=iUd%n>fei@+P`mn2N$stOMCO@@Ql+6&yk=feL*YTm;Wl!3jyx!N%zzA&;yfNaBN=9sLpoj| z!_DOI95`Nbhqwv5@p1OfR0+qBLE*9~LZOA{$Yq1aOt6?HS%eEeBMIh^LE-5c#)&c3 z&w-~0cS9+`j5vD-&rk`+kwL}?VjhbjJ~uG}SagCz$U+*LGnvM;GILx*X%Q(uIg8Ih zWC8A66d)w=1?^xGIb=-IuOU2JF$w2M7I=g>zK|tMC5H@UPGbqrMkqu6Mo@^5#uqe& zX=G6ZLH(P;vl9f7$!zf0W3JE46lRb^#!mg3!m|-Ok-ISzVOCs(XKD(EkUC;km0?1e-2DZtn{RzQ3xn`;Q;g{AbF0KMx%I zd1GI=vE=6;E}p#Vz`9>Fs+)e9=IRTVkBE-^OiY_bwQ+B4jeR?hYh^?!Y20fc#Cej=DGIz8=SN7+w=E11C@nkRro3X`A&Q|@h5?1 z-Y{l_bK-5^&7U!CvS-S4=d1^u;|eP(D$8^EJNs2trK8F#=YHG%1=(KOi~W5qe8DSt zMe?v(uHwEk0|&;h@C}Y)wW9HB_}K7Ad~E$%u~#i`DOPV(H>TpPj@F*_8VY?KcX0+j z%ACb&j0o#UQ5&`EWTCE`Rf~~ERw#I;0AyjNwo}AO{6*yC*b^6b2hza zkwie!Op;@95|r#FKPQuLq}{y})E`;YZ9uG3b6)?zPJDvI-GcZN5PvF(9iUwc+C6sK z&q#Yh(1NE;hpmd8AZ`NjK0EPA5`QL$!Ceo}xgc%^@ew<5e=HY#0hViPuzUOk*sfh> z(w`~kwGos4Oqs83GwIJ%2@Z$|Yy?^NR(|kHh?o-8dn*5NYZ}aly56X6Pd%(I!dzK&X9he3zJGHT zcjy!Nen$ED=TC?UnSzNlmGkNjC|-`zf~Oy3WA|RitBpi#0c{a9p&aG_;CARl8=zn= zp`Cbor~s%a?nAgS0TwalUWXKGZ+vMA$Val!BFG?iOr93sN2-48yC3C|J3D zkk7Ff8_Zy($z%ogscPGWK1UaKKONgp0y|IKM?yf!ehp-P0*(}XZt}#;WU&(pDOJR3 zkl{_l5gd*ZK)4f!4XhVzLJ+$s1a=yNKoOq<15?qNZ*Dt73V(&5j(Hr%5ztH4#M0Mf zNXL=@O|U@<;eC%e5ZD_v85)Lt^7p-oURN54aKs=8*!PN9D$W_OV~y&A_^zJlJ_^Oa z+-9G{z_CuxNJM;&PCy;pDRod$#<19t@SK3pjwlQ3Hzi(ghUuI`-nc2aWRFBEjQ$lLI@z(SBi}HbR8fLQ24f_Ka3G*Mq%$=EXB0S$Af=RrCZOp#)CZaiIj6G2;PAKgl;x_gc1a> zpJi&np14IgP6>R@BJ8$Vgicrlyy5xh@z-@x~kU?5E6Kvn2v7|bA{cX(ii25eOH*Ng}#V?+aHARuD^)BUr~ z9@h`&ZF+pm*-J++oV@G^XiVR3R{BQVrIXPfcW!!e;G`F~Iq=W6@4{_5=M zD80{F*HLbKz-TGHZUIJb55~^$6(&!;rSCy*8Y3mFDB+hJbTAJw#{5Gz(7j!IY6yrJ@~0VIIU!aHwG zXcQ~iX6dw=8?kQ1!j=UP!tZQ;qw??RSlY4xZEbTSml&oKYd+2eEC{75L(N#5a0mYb zlBYMngEfj+(~@W)syf0q-C!P=os4X(Tcv+PS*%OsiWU^2EO`)~uDP*l`-Lnd7qsBH z$@F=wIiY#Ofn$hXO~*%CyqUL+sK=3C+Gs|H(Ww+2cT=3S(SNdrGi8B{3SMGi}=Q0M@7^mN9G3=!5AdSB7J7=6N4O<>PKJunp?-_Y>87o?) z3=n<7!vhRD*X^P*Y4k7q4iWph2AURZ(*PI4?r%}&$p(+qT)<8B71J>xEC zeY6`c6|>%q1HRz2$0C*eHX~Ru@LzV?%eWyv^rxQo(eAg-*0hgy_jq}mgSYv1%dfMvoZGf(ZS&+dCmwg;Hs8(irr&1c(q-G` zZQNDcytK_j+njUH4COwvnL)?IJ!VmN+!JG=D%t*P+yRq9I$!2Vze}u~d|$%d%DU-e zW!+q{%DNFyaD%vK$x0IXQ=2P|yHV!ZSFNlA!^rJlX32FcvG5mt7p|m$t>8W{DJbjs z(v^AK$ce9ANkONGOrv;3W`m>agTVy)ahJ4ZS#9TS^B+d3_mwW8{?t<(RAFA>r<{fCgY={bnSeCg5Y zY;$WObAPJ=Xi?z0YrJ_c`(1 zEAd6E+7Fe__+3SO*Q&NDf>YyDeccMJ;QMTmh5`8QJHB|OjopUtUinq;ho|`dRr5pT z$)DnfhLB#6@&T5{;`><5C(FmLAhxS~QiKcQRxXI!xFGImTKiB=lFQMwHkgB7Z+0~8 zen@F)T01{yhRX>chz0W6qMUnN+4wHz$N+x4rD^wktd~!U^>Pu`%SX6g-p}>&M_ezT z#CjP&U4RrXzEa|MY2i68@inqeeJ9XkTv*+l#OfKZOK##paJyrvAc+NBIebWM+7rY| z+MD>as|H)YaGy;Rf7D@I+O($#>uElB4xSKB%AOn^dR6o5SW-7l$T_sz=RV1-juf!g z!YrTr2#$rCo3W~Hn*F1Wwo8Eg7T48pV_i*5#|8W}5+;O)T-Mxt;A7#@J_f-_F0S35 z`ocqR!l^Kt*oG6|CtP)423x``wD;2H_YZv3wC6`1U4v2Wb(6zGu0*{@1~*NZcW9Rf ztbpiH3Wz>t7FfaN94gvAh&=8AD?`5NZV`0=K70YO?O-JB!sceIx5e2p7W*ye3u1>) zL4G6Lbo1+2Y$q;pJti30#5SUrvOoeKkAssT*G>+n4I^FCgo;CLlnHVUrZlaYqI56| zoWPx<+I4JlHyBA94g-iTcjgmwNU*XOtPCA2_%($BA1@daQb`+&HMPl0ook8U8*gjApkCNI0dOHvlr+&}~s@^Vn{5(O_w zkUNgEv1fru1ky-?QF%|OQq!LKSaxG@mI*$Z$Okp$A*{Xm+zCdKvN6I}nwm1J`p_;Z z6==$l?k&s`LXnz+cH1ri@|#?P3rztAnu1Rw0UB{pkEY;6nR275i19Hs1>^H9c)>O| z%RnYnl(iHIYP}XiJj_&-S1s_8O z*%SpQ%IFGQ{imQGix`7K$Lx7t@bMO#fWitr!DAN~NpoX>F?paTv$9M*LCzslPnZQp z5R0JJ&1`WO7~$N(*kGMb48{hZQE-xWsZCEnLH(sfICq4`Cf2$5C3(amWW-X~wNzNt zdL2gBRFVnVaeZ#!z5UA?7cAlb-!Lxwv8F!<#$|u^HpOcr za^nAnaglt)jmt;a9QXpp<$u?}x|wf}Uyn0t?(60D#4J#?9)r?5Q0cKFY%Lz25U__P zR#?(wG`QgR#v_s5QAqCqq$l{G&RcN@pY1V6JlyE927JLx!yW*ciC)Ujp75e~Xwef@ z^qTYp?7>7lju71tAq3wf^JD*=wYS4(+rx&YULiwIw7_cPlbOJOGmjyu0&EGe34{gs z?3uGoy<&l$smfa8Apilp_qV!1Cu2H&2x?B;+4pwzO}%`4+`p5JxN9e1ck5Q5gM%S! z!acwa{R+U0Bf$z}Yx zAO0$0&CS_?S(xDX?qvIr;-#VXY%l))=Ey1PAO5b*emBqYfx7DZ_*O-;13xr88fFDQ zefh5WC&2Odu3=jMUvxKqA6)+RZTSnA{HbF8dV2V-7ux^8-)&M?EpixF02Au0#-A2N zeV*6&+gt5*cuw%VhTpPPqCtF{tk3&44avX9-;&4gFQ((ymbZ#u&#VC{{tyg)aep;_ z2TJ_eb^NiR|{+Un_n?ni~Y_OIhw%Zq3PubJZtWG)IeZ48J6Z9}g!A zzgaIRF7k&*YN(*5>qfQi6QJt02G#b$rRq!59sGlgbz1{7kQY3lzI30%9T7#Jh~UT1 z6CK9$sLrhGaTLtcefZm3yYYt^Yc?nGZwYwx+ZB)Q!*5^l4~O!Pl*hZ0tK_x#CEPSA9r1({o1w}xVm5;0n&MKT+R2HZLXtp}CxTLg5R$5h5T0FXV z&gkn*;`v4M%PSwi^PG~Z!daz7BNrCU8d+6TSUGzhkh2%g!Do3eP!SA_MCXOZPUIpQv#z$ zi7c-tZ_2c(Gp9_xe#C#I$=~SG!eH6# zc}10@s_+vu%IKoN>{HWK7R@a|&kF-3S`p8ngP zU0xQbEH5>iKMkL=3TNL}R5nM_b=AdUFXAo_#bR$h5{vyD&uoet9K5lU5>NSzKX@VT z6r|ixV2Z!4pkVh_+(}4z6UW=SespEw?AaLQnE?!D*_Ah5IpWHq>WY%eqNV4&ZsmeBQ{zWRPe_So;HA9D|m#4fz}u6h6YMB8+YT8X}?}{Tl%d_<|Y69x9^0mxb*e5yQk&*rcR&kpIK6P-L;r9eQH;f+|T`+ zr)cuu=kq>~$r@ObX!|#~hW`?4RFMv@QJ=Wr^Zx_|X%hMLlM8zOJ#xXB{j;^0_%9dq z{Hwj-%>G&Ba}~$e5^s7M1AZ+e6S)t6P!7)p<*`@>27MXQIT*~yLi6vkM5<%4EZ|#_ zjzgOLKrFU|bV&K9BeQ=Pi?tzLij;q&EaOM982_4C1$1mV(q%}Sh=;CqAT2=3zx$f8 zI2OyN@;n-gJ&1JKKgVL5kmmkdES8D(Tc1MvNHhK&c%=DA+mJecjzeoGVm8vdkme#S zMVj%8SnO$}PNe*sDY;0Skme)ZLHK{gVt+@v9BC>PJ7ZZaHWXO2NIQ_WBF#7ui+zYR8)+v}UigiCaXEf?o8qX>aC|LoK6uFsz3I-yN#69VQ1Ybo?0W~L zXH80XdeSo|q-RV>PxTB&&O|$6?Yd2`n>8<#fkoOXDBHl-)vrV6WMJ43D7?e`gbOQGl zaI=Z)ky~E+az~L}AS~~~9L=nW#U_y!mxenBxEa7bi+W5R(X8Zga-7GBCXY1}5+8P) zibMRpUlgxHT`Oua$MJ0PqM2jZ;Y`>DW+sYJ^bDqKmV@>|_@5`KlR`GopT`q#p=^9? z7h+hP;xpS_R-2RCZS=TLt^HJARsA~-V6!wLw7GIct{pmnWa$=Aw@cC|f zq0-{wIPOE88UGrKecOhk6CP*76(Da2aPxp`Wk0^tTmO1w?4^uvNzZ*44P(;wk?+*O z2`kc5IZrol$xqJ>4OpDABsrY)c;b)oH#$&lHQIRViC7F~SzN>|0j?D|%g1x>ECX&6 za9gBIm(X4;roF)2Sz{^(=FU|1lXbL$zVyl7^?nH4Jm4~s&x>@Ozy*NA-z5?k^U}af z1#sxmH{_YU`$*-aQlF}&xWH<)Zl|=*l^Xr zJpr7ZZV7N5z|FJKEd$Q~vmTrmc`pLD1Gx8a+@vHFUVy*9l;ByMIAKZBgmChN$5ST! zc)%PrW#JAb<*L-g>)~*dkS?tg*sl`5{q3$2Xjh3?zg&_ zusSU{^%gaGVKw^u1Ee`fy-3-ApTsB5Na`1xUXAvW*lxdI=!#^D|V0cv|< zv0oAa8}xVrY+pDL^L$AXrom#`pioMJ4{i*48aKxpb(G};D8B;bL)5eH(Fel5FG=*! z2cmq4=Ou7n0_P=gUIOPOa9#rEC2(E>=Ou7n0%sxt zyhO<>!p#m`34b)xTbG+@g-pXTeL<#g$+S(r8ZEYk%tT`bclW%`^OrZ8H5>rblI(a;0ovrq{^y2ANKh=^ZjHmgxeSE|%$&GJQ^_FUfSB zOt;DOW0@Y6Y06cyeVJY((;H+uNv3zmv{u3Cr_Ms zqcdyr)Z2$U-D5^wH!53EMpezjxpJUzmNKdYe^9=%LK#(79w-_$w=6gc2iX-xm3T+M zMB#LNWT0@aBJ$=HR?SmJ&3T}#>Vf%Y8mKgL78F%h;gq@;FcWz^3ugiOR8bmWlO_1X z3J;*)PlfSh#EOU@w1{EN`Vwi%_PPowm?9c%aF&+OL-7t@J5&4wq%Gt@f>Y#vtz~ zY?)QwviDZ%5~V+1UZNGAJy3>LdCMPIsUht-`$$%-_AS3K2{^KBmACwhm0JD;B;u0Y zu|Q=io@lGB@|JzJ(uXCb)qji5O7BKKZNCLu_TNe?Wcj}QOSEz?9wpW-3t5Fr#j^i z$h4PNUXs)@t*`_o(S1PW72p{H$!V`x@t>9UrMF-!ZAMv!R91N_KD1Jc4kY5TU@LtC zSn_X`kI1;iN@<_$msQTfeSin*j#b`@d#uzFEax9DtGuOuj{#?I--;)#G}RgyQM#wR zRnI4V$}6psP^MNp5*u&dg7@Qrby@spSmk7D**S~8@Aw}=2Kmpn2xMy2^OZh$(( + +#define DBL_VERSION 2u +#define DBL_ORG_UID 2000u +#define DBL_BROKER_UID 2100u +#define DBL_MAX_PROMPT 65536u +#define DBL_MAX_TOKEN 4096u +#define DBL_MAX_CAPABILITY_BUNDLE (DBL_MAX_TOKEN * 2u + 4u) +#define DBL_MAX_OUTPUT 65536u +#ifndef DBL_REGISTRY +#define DBL_REGISTRY "/etc/daimon-engine-broker/registrations.bin" +#endif +#ifndef DBL_SOCKET +#define DBL_SOCKET "/run/daimon-engine-broker/launcher.sock" +#endif +#define DBL_CONTROL_SOCKET "/run/daimon-engine-broker/control.sock" +#define DBL_BACKEND_SOCKET "/run/daimon-engine-broker/backend.sock" +#define DBL_MAX_CONTROL_FRAME 1048576u +#ifndef DBL_EXECUTABLE +#define DBL_EXECUTABLE "/usr/local/bin/grok" +#endif + +struct dbl_request { + uint32_t version, slot; + char request_id[65], turn_id[65], agent_id[129], wake_id[129]; +}; +struct dbl_registration { + uint32_t version, slot, uid, gid; + char agent_id[129], workspace[256], home[256]; + uint8_t executable_sha256[32]; +}; +enum dbl_result_status { + DBL_STATUS_OK = 0, + DBL_STATUS_PRELAUNCH_FAILED = 1, + DBL_STATUS_WORKER_FAILED = 2, + DBL_STATUS_OUTPUT_FAILED = 3, + DBL_STATUS_CANCELLED = 4 +}; +enum dbl_result_stage { + DBL_STAGE_NONE = 0, + DBL_STAGE_PEER = 1, + DBL_STAGE_REQUEST = 2, + DBL_STAGE_REGISTRATION = 3, + DBL_STAGE_EXECUTABLE = 4, + DBL_STAGE_EXEC = 5, + DBL_STAGE_WAIT = 6, + DBL_STAGE_OUTPUT = 7, + DBL_STAGE_ATTESTATION = 8 +}; +enum dbl_failure_class { + DBL_FAILURE_NONE = 0, + DBL_FAILURE_PEER = 1, + DBL_FAILURE_PROTOCOL = 2, + DBL_FAILURE_REGISTRATION = 3, + DBL_FAILURE_EXECUTABLE = 4, + DBL_FAILURE_EXEC = 5, + DBL_FAILURE_WAIT = 6, + DBL_FAILURE_OUTPUT_LIMIT = 7, + DBL_FAILURE_CANCELLED = 8, + DBL_FAILURE_ATTESTATION_PROFILE_MISSING = 9, + DBL_FAILURE_ATTESTATION_PROFILE_INVALID = 10 +}; +struct dbl_result { + uint32_t version, status, worker_uid, output_length; + int32_t worker_pid, exit_code, term_signal; + uint64_t start_ticks; + char turn_id[65]; + uint32_t stage, failure_class, profile_applied, reserved; +}; +#define DBL_RESULT_SIZE 128u +#define DBL_RESULT_STAGE_OFFSET 108u +#define DBL_RESULT_FAILURE_CLASS_OFFSET 112u +#define DBL_RESULT_PROFILE_APPLIED_OFFSET 116u +#define DBL_RESULT_RESERVED_OFFSET 120u +_Static_assert(sizeof(struct dbl_result) == DBL_RESULT_SIZE, + "dbl_result ABI size"); +_Static_assert(__builtin_offsetof(struct dbl_result, stage) == + DBL_RESULT_STAGE_OFFSET, + "dbl_result stage offset"); +_Static_assert(__builtin_offsetof(struct dbl_result, failure_class) == + DBL_RESULT_FAILURE_CLASS_OFFSET, + "dbl_result failure offset"); +_Static_assert(__builtin_offsetof(struct dbl_result, profile_applied) == + DBL_RESULT_PROFILE_APPLIED_OFFSET, + "dbl_result profile offset"); +_Static_assert(__builtin_offsetof(struct dbl_result, reserved) == + DBL_RESULT_RESERVED_OFFSET, + "dbl_result reserved offset"); + +#endif diff --git a/src/runtime/native/engineBrokerLauncher.integration.c b/src/runtime/native/engineBrokerLauncher.integration.c new file mode 100644 index 0000000..17c8de4 --- /dev/null +++ b/src/runtime/native/engineBrokerLauncher.integration.c @@ -0,0 +1,4 @@ +#include "engineBrokerLauncherIntegrationCore.inc" +#include "engineBrokerLauncherIntegrationRelay.inc" +#include "engineBrokerLauncherIntegrationLauncher.inc" +#include "engineBrokerLauncherIntegrationMain.inc" diff --git a/src/runtime/native/engineBrokerLauncherCore.inc b/src/runtime/native/engineBrokerLauncherCore.inc new file mode 100644 index 0000000..8329892 --- /dev/null +++ b/src/runtime/native/engineBrokerLauncherCore.inc @@ -0,0 +1,348 @@ +#define _GNU_SOURCE +#include "engineBrokerLauncher.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static __attribute__((noreturn)) void die(void) { _exit(111); } +static int bounded(const char *s, size_t n) { return memchr(s, 0, n) != NULL; } +static int safe_component(const char *s, size_t n) { + size_t i, l = strnlen(s, n); + if (!l || l == n) + return 0; + for (i = 0; i < l; i++) + if (!((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z') || + (s[i] >= '0' && s[i] <= '9') || s[i] == '-' || s[i] == '_' || + s[i] == ':' || s[i] == '.')) + return 0; + return 1; +} +static int full_read(int fd, void *buf, size_t n) { + size_t o = 0; + ssize_t r; + while (o < n) { + r = read(fd, (char *)buf + o, n - o); + if (r < 1) + return -1; + o += (size_t)r; + } + return 0; +} +static int full_write(int fd, const void *buf, size_t n) { + size_t o = 0; + ssize_t r; + while (o < n) { + r = write(fd, (const char *)buf + o, n - o); + if (r < 1) + return -1; + o += (size_t)r; + } + return 0; +} +static void erase(void *value, size_t n) { + volatile unsigned char *p = value; + while (n--) + *p++ = 0; +} + +static int receive_request(int fd, struct dbl_request *r, int passed[2]) { + char control[CMSG_SPACE(sizeof(int) * 2)]; + struct iovec io = {r, sizeof(*r)}; + struct msghdr m = {0}; + struct cmsghdr *c; + ssize_t n; + memset(control, 0, sizeof(control)); + m.msg_iov = &io; + m.msg_iovlen = 1; + m.msg_control = control; + m.msg_controllen = sizeof(control); + n = recvmsg(fd, &m, MSG_CMSG_CLOEXEC | MSG_WAITALL); + if (n != (ssize_t)sizeof(*r) || m.msg_flags & (MSG_TRUNC | MSG_CTRUNC)) + return -1; + c = CMSG_FIRSTHDR(&m); + if (!c || CMSG_NXTHDR(&m, c) || c->cmsg_level != SOL_SOCKET || + c->cmsg_type != SCM_RIGHTS || c->cmsg_len != CMSG_LEN(sizeof(int) * 2)) + return -1; + memcpy(passed, CMSG_DATA(c), sizeof(int) * 2); + return 0; +} + +static int sealed_regular(int fd, size_t maximum) { + struct stat s; + int seals; + if (fstat(fd, &s) || !S_ISREG(s.st_mode) || s.st_size < 1 || + (uint64_t)s.st_size > maximum) + return 0; + seals = fcntl(fd, F_GET_SEALS); + return seals >= 0 && (seals & (F_SEAL_WRITE | F_SEAL_SHRINK | F_SEAL_GROW)) == + (F_SEAL_WRITE | F_SEAL_SHRINK | F_SEAL_GROW); +} +static int valid_token(const unsigned char *value, size_t length) { + if (!length || length > DBL_MAX_TOKEN) + return 0; + for (size_t i = 0; i < length; i++) + if (!((value[i] >= 'A' && value[i] <= 'Z') || + (value[i] >= 'a' && value[i] <= 'z') || + (value[i] >= '0' && value[i] <= '9') || value[i] == '.' || + value[i] == '_' || value[i] == '~' || value[i] == '-')) + return 0; + return 1; +} +static int capability_bundle(int fd, unsigned char provider[DBL_MAX_TOKEN + 1], + unsigned char mcp[DBL_MAX_TOKEN + 1]) { + unsigned char bytes[DBL_MAX_CAPABILITY_BUNDLE] = {0}; + struct stat s; + size_t offset = 0; + uint16_t provider_length, mcp_length; + int ok = -1; + if (!sealed_regular(fd, DBL_MAX_CAPABILITY_BUNDLE) || fstat(fd, &s) || + pread(fd, bytes, (size_t)s.st_size + 1, 0) != s.st_size || + (size_t)s.st_size < 6) + goto done; + provider_length = (uint16_t)(bytes[0] | ((uint16_t)bytes[1] << 8)); + offset = 2; + if (!valid_token(bytes + offset, provider_length) || + offset + provider_length + 2 > (size_t)s.st_size) + goto done; + if (provider) + memcpy(provider, bytes + offset, provider_length); + offset += provider_length; + mcp_length = (uint16_t)(bytes[offset] | ((uint16_t)bytes[offset + 1] << 8)); + offset += 2; + if (!valid_token(bytes + offset, mcp_length) || + offset + mcp_length != (size_t)s.st_size || + (provider_length == mcp_length && + !memcmp(bytes + 2, bytes + offset, mcp_length))) + goto done; + if (mcp) + memcpy(mcp, bytes + offset, mcp_length); + ok = 0; +done: + erase(bytes, sizeof(bytes)); + return ok; +} +static int load_registration(uint32_t slot, struct dbl_registration *out) { + int fd = open(DBL_REGISTRY, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + struct stat s; + struct dbl_registration r; + if (fd < 0 || fstat(fd, &s) || !S_ISREG(s.st_mode) || s.st_uid != 0 || + (s.st_mode & 022) != 0) + return -1; + while (full_read(fd, &r, sizeof(r)) == 0) + if (r.version == DBL_VERSION && r.slot == slot) { + *out = r; + close(fd); + return 0; + } + close(fd); + return -1; +} +static int valid_registration(const struct dbl_registration *r, + const struct dbl_request *q) { + return r->uid >= 2200 && r->gid >= 2200 && + bounded(r->workspace, sizeof(r->workspace)) && + bounded(r->home, sizeof(r->home)) && r->workspace[0] == '/' && + r->home[0] == '/' && + safe_component(r->agent_id, sizeof(r->agent_id)) && + strcmp(r->agent_id, q->agent_id) == 0; +} +static void close_other_fds(int keep) { + long max = sysconf(_SC_OPEN_MAX); + int fd; + for (fd = 3; fd < (max > 65536 ? 65536 : (int)max); fd++) + if (fd != 3 && fd != 4 && fd != 5 && fd != keep) + close(fd); +} +static void zero_caps(void) { + struct __user_cap_header_struct h = {_LINUX_CAPABILITY_VERSION_3, 0}; + struct __user_cap_data_struct d[2] = {{0}}; + if (!syscall(SYS_capset, &h, d)) + return; + memset(d, 0, sizeof(d)); + if (syscall(SYS_capget, &h, d) || d[0].effective || d[0].permitted || + d[0].inheritable || d[1].effective || d[1].permitted || d[1].inheritable) + die(); +} +static void drop_bounding_caps(void) { + int cap; + for (cap = 0; cap < 256; cap++) { + int present = prctl(PR_CAPBSET_READ, cap, 0, 0, 0); + if (present < 0) { + if (errno == EINVAL) + break; + die(); + } + if (present && prctl(PR_CAPBSET_DROP, cap, 0, 0, 0)) + die(); + } + if (cap == 256) + die(); + for (int verify = 0; verify < cap; verify++) + if (prctl(PR_CAPBSET_READ, verify, 0, 0, 0) != 0) + die(); +} +static int executable_fd(const struct dbl_registration *r) { + int fd = open(DBL_EXECUTABLE, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + struct stat s; + SHA256_CTX ctx; + unsigned char digest[32], buf[8192]; + ssize_t n; + if (fd < 0 || fstat(fd, &s) || !S_ISREG(s.st_mode) || s.st_uid != 0 || + (s.st_mode & 022) || !(s.st_mode & 0111) || !SHA256_Init(&ctx)) + goto bad; + while ((n = read(fd, buf, sizeof(buf))) > 0) + if (!SHA256_Update(&ctx, buf, (size_t)n)) + goto bad; + if (n < 0 || !SHA256_Final(digest, &ctx) || + memcmp(digest, r->executable_sha256, sizeof(digest)) || + lseek(fd, 0, SEEK_SET) < 0) + goto bad; + return fd; +bad: + if (fd >= 0) + close(fd); + return -1; +} +static uint64_t start_ticks(pid_t pid) { + char p[64], b[1024], *x; + int fd; + ssize_t n; + unsigned field = 3; + snprintf(p, sizeof(p), "/proc/%ld/stat", (long)pid); + fd = open(p, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return 0; + n = read(fd, b, sizeof(b) - 1); + close(fd); + if (n < 1) + return 0; + b[n] = 0; + x = strrchr(b, ')'); + if (!x) + return 0; + for (x += 2; *x; x++) { + if (*x == ' ') + field++; + if (field == 22) + return strtoull(x, NULL, 10); + } + return 0; +} + +static __attribute__((noreturn)) void launch_fail(int status_fd, + uint32_t code) { + (void)full_write(status_fd, &code, sizeof(code)); + _exit(111); +} +static pid_t launch(const struct dbl_registration *r, int executable, + int prompt, int capability, int output, uint32_t *failure, + uint64_t *observed_start_ticks) { + unsigned char mcp[DBL_MAX_TOKEN + 1] = {0}; + int status_pipe[2]; + if (capability_bundle(capability, NULL, mcp) || pipe2(status_pipe, O_CLOEXEC)) + return -1; + pid_t p = fork(); + if (p < 0) { + close(status_pipe[0]); + close(status_pipe[1]); + erase(mcp, sizeof(mcp)); + return -1; + } + if (p != 0) { + uint32_t code = 0; + close(status_pipe[1]); + if (full_read(status_pipe[0], observed_start_ticks, + sizeof(*observed_start_ticks)) || + !*observed_start_ticks) { + close(status_pipe[0]); + erase(mcp, sizeof(mcp)); + kill(p, SIGKILL); + waitpid(p, NULL, 0); + return -1; + } + ssize_t got = read(status_pipe[0], &code, sizeof(code)); + close(status_pipe[0]); + erase(mcp, sizeof(mcp)); + if (got == 0) + return p; + if (got == (ssize_t)sizeof(code)) + *failure = code; + kill(p, SIGKILL); + waitpid(p, NULL, 0); + return -1; + } + close(status_pipe[0]); + if (prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() == 1 || setpgid(0, 0)) + launch_fail(status_pipe[1], 1); + uint64_t identity = start_ticks(getpid()); + if (!identity || full_write(status_pipe[1], &identity, sizeof(identity))) + launch_fail(status_pipe[1], 1); + struct rlimit z = {0, 0}; + if (setrlimit(RLIMIT_CORE, &z) || setgroups(0, NULL) || + prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0) || setresgid(r->gid, r->gid, r->gid) || + setresuid(r->uid, r->uid, r->uid)) + launch_fail(status_pipe[1], 2); + zero_caps(); + if (prctl(PR_SET_KEEPCAPS, 0, 0, 0, 0) || + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) + launch_fail(status_pipe[1], 3); + if (dup2(prompt, 3) < 0 || dup2(capability, 4) < 0 || + dup2(output, STDOUT_FILENO) < 0 || dup2(output, STDERR_FILENO) < 0) + launch_fail(status_pipe[1], 4); + if (executable != 5 && dup2(executable, 5) < 0) + launch_fail(status_pipe[1], 5); + close_other_fds(status_pipe[1]); + char *const argv[] = {"grok", + "--sandbox", + "daimon-strict", + "--always-approve", + "--no-subagents", + "--prompt-file", + "/proc/self/fd/3", + "--no-memory", + "--disable-web-search", + "--cwd", + (char *)r->workspace, + "--output-format", + "streaming-messages-json", + "--model", + "daimon-broker-grok", + NULL}; + char home[300], grok[300], mcp_env[DBL_MAX_TOKEN + 24]; + snprintf(home, sizeof(home), "HOME=%s", r->home); + snprintf(grok, sizeof(grok), "GROK_HOME=%s/.grok", r->home); + snprintf(mcp_env, sizeof(mcp_env), "DAIMON_MCP_CAPABILITY=%s", mcp); + erase(mcp, sizeof(mcp)); + char *const envp[] = {home, + grok, + mcp_env, + "DAIMON_CAPABILITY_FD=4", + "PATH=/usr/local/bin:/usr/bin:/bin", + "LANG=C.UTF-8", + "TZ=UTC", + NULL}; + syscall(SYS_execveat, 5, "", argv, envp, AT_EMPTY_PATH); + erase(mcp_env, sizeof(mcp_env)); + launch_fail(status_pipe[1], 6); +} + diff --git a/src/runtime/native/engineBrokerLauncherIntegrationCore.inc b/src/runtime/native/engineBrokerLauncherIntegrationCore.inc new file mode 100644 index 0000000..25ff703 --- /dev/null +++ b/src/runtime/native/engineBrokerLauncherIntegrationCore.inc @@ -0,0 +1,390 @@ +#define _GNU_SOURCE +#include "engineBrokerLauncher.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void check(int ok, const char *m) { + if (!ok) { + perror(m); + exit(1); + } +} +static int write_all(int fd, const void *bytes, size_t length) { + size_t offset = 0; + while (offset < length) { + ssize_t n = write(fd, (const char *)bytes + offset, length - offset); + if (n < 1) + return 0; + offset += (size_t)n; + } + return 1; +} +static int read_all(int fd, void *bytes, size_t length) { + size_t offset = 0; + while (offset < length) { + ssize_t n = read(fd, (char *)bytes + offset, length - offset); + if (n < 1) + return 0; + offset += (size_t)n; + } + return 1; +} +static int wait_bounded(pid_t pid, int *status) { + for (int attempt = 0; attempt < 250; attempt++) { + pid_t observed = waitpid(pid, status, WNOHANG); + if (observed == pid) + return 1; + if (observed < 0) + return 0; + usleep(20000); + } + kill(pid, SIGKILL); + waitpid(pid, status, 0); + return 0; +} +static int result_padding_zero(const struct dbl_result *result) { + const unsigned char *bytes = (const unsigned char *)result; + const size_t padding[] = {28, 29, 30, 31, 105, 106, 107, 124, 125, 126, 127}; + for (size_t index = 0; index < sizeof(padding) / sizeof(padding[0]); index++) + if (bytes[padding[index]] != 0) + return 0; + return 1; +} +static void wait_launcher_ready(pid_t pid) { + for (int attempt = 0; attempt < 250; attempt++) { + struct stat socket_identity; + if (kill(pid, 0)) + break; + if (!lstat(DBL_SOCKET, &socket_identity) && + S_ISSOCK(socket_identity.st_mode) && socket_identity.st_uid == 0 && + socket_identity.st_gid == DBL_BROKER_UID && + (socket_identity.st_mode & 0777) == 0660) + return; + usleep(20000); + } + check(0, "launcher readiness"); +} +static void digest(const char *path, unsigned char out[32]) { + int f = open(path, O_RDONLY); + char b[4096]; + ssize_t n; + SHA256_CTX c; + check(f >= 0 && SHA256_Init(&c), "hash setup"); + while ((n = read(f, b, sizeof(b))) > 0) + check(SHA256_Update(&c, b, (size_t)n), "hash update"); + check(n == 0 && SHA256_Final(out, &c), "hash final"); + close(f); +} +static int sealed(const char *value) { + int f = memfd_create("broker-test", MFD_ALLOW_SEALING); + check(f >= 0 && write(f, value, strlen(value)) == (ssize_t)strlen(value), + "memfd"); + check(!fcntl(f, F_ADD_SEALS, + F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_SEAL), + "seal"); + lseek(f, 0, SEEK_SET); + return f; +} +static size_t make_bundle(const char *provider, const char *mcp, + unsigned char bytes[DBL_MAX_CAPABILITY_BUNDLE]) { + uint16_t p = (uint16_t)strlen(provider), m = (uint16_t)strlen(mcp); + size_t n = 0; + bytes[n++] = (unsigned char)p; + bytes[n++] = (unsigned char)(p >> 8); + memcpy(bytes + n, provider, p); + n += p; + bytes[n++] = (unsigned char)m; + bytes[n++] = (unsigned char)(m >> 8); + memcpy(bytes + n, mcp, m); + return n + m; +} +static int sealed_bundle(const char *provider, const char *mcp) { + unsigned char bytes[DBL_MAX_CAPABILITY_BUNDLE]; + size_t n = make_bundle(provider, mcp, bytes); + int f = memfd_create("bundle", MFD_ALLOW_SEALING); + check(f >= 0 && write_all(f, bytes, n), "bundle"); + check(!fcntl(f, F_ADD_SEALS, + F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_SEAL), + "bundle seal"); + lseek(f, 0, SEEK_SET); + memset(bytes, 0, sizeof(bytes)); + return f; +} +static void send_request(int s, struct dbl_request *q, int p, int c) { + char control[CMSG_SPACE(sizeof(int) * 2)] = {0}; + int f[2] = {p, c}; + struct iovec io = {q, sizeof(*q)}; + struct msghdr m = {.msg_iov = &io, + .msg_iovlen = 1, + .msg_control = control, + .msg_controllen = sizeof(control)}; + struct cmsghdr *h = CMSG_FIRSTHDR(&m); + h->cmsg_level = SOL_SOCKET; + h->cmsg_type = SCM_RIGHTS; + h->cmsg_len = CMSG_LEN(sizeof(f)); + memcpy(CMSG_DATA(h), f, sizeof(f)); + check(sendmsg(s, &m, 0) == sizeof(*q), "sendmsg"); +} +static void send_one(int s, struct dbl_request *q, int p) { + char control[CMSG_SPACE(sizeof(int))] = {0}; + struct iovec io = {q, sizeof(*q)}; + struct msghdr m = {.msg_iov = &io, + .msg_iovlen = 1, + .msg_control = control, + .msg_controllen = sizeof(control)}; + struct cmsghdr *h = CMSG_FIRSTHDR(&m); + h->cmsg_level = SOL_SOCKET; + h->cmsg_type = SCM_RIGHTS; + h->cmsg_len = CMSG_LEN(sizeof(p)); + memcpy(CMSG_DATA(h), &p, sizeof(p)); + check(sendmsg(s, &m, 0) == sizeof(*q), "send one"); +} +static int connect_socket(void) { + int s = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un a = {.sun_family = AF_UNIX}; + strcpy(a.sun_path, DBL_SOCKET); + check(s >= 0 && !connect(s, (void *)&a, sizeof(a)), "connect"); + return s; +} +static struct dbl_request request(void) { + struct dbl_request q = {.version = DBL_VERSION, .slot = 7}; + strcpy(q.request_id, "request-1"); + strcpy(q.turn_id, "turn-1"); + strcpy(q.agent_id, "agent-1"); + strcpy(q.wake_id, "wake-1"); + return q; +} +static void client_case(void) { + int input[2], output[2], status; + check(!pipe(input) && !pipe(output), "client pipes"); + pid_t child = fork(); + if (!child) { + dup2(input[0], 0); + dup2(output[1], 1); + close(input[1]); + close(output[0]); + execl("/opt/daimon/bin/daimon-engine-broker", "daimon-engine-broker", + "--client", NULL); + _exit(127); + } + close(input[0]); + close(output[1]); + struct dbl_request request_value = request(); + uint32_t prompt_length = DBL_MAX_PROMPT; + unsigned char capability[DBL_MAX_CAPABILITY_BUNDLE]; + uint32_t capability_length = + (uint32_t)make_bundle("provider.Token-1", "mcp.Token-2", capability); + char *prompt = malloc(prompt_length); + memset(prompt, 'p', prompt_length); + check(write(input[1], &request_value, sizeof(request_value)) == + sizeof(request_value) && + write(input[1], &prompt_length, sizeof(prompt_length)) == + sizeof(prompt_length) && + write_all(input[1], prompt, prompt_length) && + write(input[1], &capability_length, sizeof(capability_length)) == + sizeof(capability_length) && + write_all(input[1], capability, capability_length), + "client frame"); + memset(prompt, 0, prompt_length); + free(prompt); + close(input[1]); + struct dbl_result result; + ssize_t result_bytes = read(output[0], &result, sizeof(result)); + if (result_bytes == (ssize_t)sizeof(result) && + (result.status != DBL_STATUS_OK || result.stage != DBL_STAGE_OUTPUT)) + fprintf(stderr, + "diagnostic status=%u stage=%u class=%u exit=%d signal=%d pid=%d " + "uid=%u ticks=%llu bytes=%u\n", + result.status, result.stage, result.failure_class, result.exit_code, + result.term_signal, result.worker_pid, result.worker_uid, + (unsigned long long)result.start_ticks, result.output_length); + check( + result_bytes == sizeof(result) && result_padding_zero(&result) && + result.status == DBL_STATUS_OK && result.stage == DBL_STAGE_OUTPUT && + result.failure_class == DBL_FAILURE_NONE && !result.profile_applied && + !result.reserved && result.output_length < DBL_MAX_OUTPUT, + "client result"); + char *out = calloc(1, result.output_length + 1); + check(read(output[0], out, result.output_length) == + (ssize_t)result.output_length, + "client output"); + if (!strstr(out, "uid=2200") || strstr(out, "provider.Token-1") || + strstr(out, "mcp.Token-2")) { + fprintf(stderr, "worker status=%u exit=%d signal=%d bytes=%u\n", + result.status, result.exit_code, result.term_signal, + result.output_length); + check(0, "client secrecy"); + } + check(read(output[0], out, 1) == 0, "client exact output"); + free(out); + close(output[0]); + waitpid(child, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, "client status"); +} +static void client_input_reject(uint32_t claimed, size_t supplied) { + int input[2], output[2], status; + check(!pipe(input) && !pipe(output), "reject pipes"); + pid_t child = fork(); + if (!child) { + dup2(input[0], 0); + dup2(output[1], 1); + close(input[1]); + close(output[0]); + execl("/opt/daimon/bin/daimon-engine-broker", "daimon-engine-broker", + "--client", NULL); + _exit(127); + } + close(input[0]); + close(output[1]); + struct dbl_request value = request(); + write(input[1], &value, sizeof(value)); + write(input[1], &claimed, sizeof(claimed)); + if (supplied) + write(input[1], "x", supplied); + close(input[1]); + char byte; + check(read(output[0], &byte, 1) == 0, "rejected client silence"); + close(output[0]); + waitpid(child, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 111, + "client input rejection"); +} +static void auth_reject(uid_t uid, int fd, int extra) { + int output[2], errors[2], status; + check(!pipe(output) && !pipe(errors), "auth pipes"); + pid_t child = fork(); + if (!child) { + if (uid) { + setgid(uid); + setuid(uid); + } + dup2(fd, 4); + dup2(output[1], 1); + dup2(errors[1], 2); + close(output[0]); + close(errors[0]); + if (extra) + execl("/opt/daimon/bin/daimon-engine-broker", "daimon-engine-broker", + "--auth-provider", "override", NULL); + else + execl("/opt/daimon/bin/daimon-engine-broker", "daimon-engine-broker", + "--auth-provider", NULL); + _exit(127); + } + close(output[1]); + close(errors[1]); + char byte; + check(read(output[0], &byte, 1) == 0 && read(errors[0], &byte, 1) == 0, + "auth failure silence"); + close(output[0]); + close(errors[0]); + waitpid(child, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 111, "auth rejection"); +} +static void auth_adversarial_cases(void) { + int good = sealed_bundle("opaque.Token-1", "mcp.Token-2"); + auth_reject(0, good, 0); + auth_reject(2200, good, 1); + int unsealed = memfd_create("unsealed", MFD_ALLOW_SEALING); + write(unsealed, "token", 5); + auth_reject(2200, unsealed, 0); + int oversized = memfd_create("oversized", MFD_ALLOW_SEALING); + char bytes[DBL_MAX_CAPABILITY_BUNDLE + 1]; + memset(bytes, 'a', sizeof(bytes)); + write(oversized, bytes, sizeof(bytes)); + fcntl(oversized, F_ADD_SEALS, + F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_SEAL); + auth_reject(2200, oversized, 0); + int malformed = sealed_bundle("same.Token", "same.Token"); + auth_reject(2200, malformed, 0); + int badlength = memfd_create("badlength", MFD_ALLOW_SEALING); + unsigned char bad[] = {5, 0, 'a', 'b', 1, 0, 'c'}; + write(badlength, bad, sizeof(bad)); + fcntl(badlength, F_ADD_SEALS, + F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_SEAL); + auth_reject(2200, badlength, 0); + memset(bytes, 0, sizeof(bytes)); + memset(bad, 0, sizeof(bad)); + close(good); + close(unsealed); + close(oversized); + close(malformed); + close(badlength); +} +static void malformed_response_case(int truncated) { + unlink(DBL_SOCKET); + int server = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un a = {.sun_family = AF_UNIX}; + strcpy(a.sun_path, DBL_SOCKET); + check(server >= 0 && !bind(server, (void *)&a, sizeof(a)) && + !chmod(DBL_SOCKET, 0777) && !listen(server, 1), + "fake server"); + int input[2], output[2]; + check(!pipe(input) && !pipe(output), "fake pipes"); + pid_t child = fork(); + if (!child) { + setgid(DBL_BROKER_UID); + setuid(DBL_BROKER_UID); + dup2(input[0], 0); + dup2(output[1], 1); + close(input[1]); + close(output[0]); + execl("/opt/daimon/bin/daimon-engine-broker", "daimon-engine-broker", + "--client", NULL); + _exit(127); + } + close(input[0]); + close(output[1]); + struct dbl_request request_value = request(); + uint32_t one = 1; + write(input[1], &request_value, sizeof(request_value)); + write(input[1], &one, sizeof(one)); + write(input[1], "p", 1); + write(input[1], &one, sizeof(one)); + write(input[1], "c", 1); + close(input[1]); + int peer = accept(server, NULL, NULL); + char inbound[sizeof(struct dbl_request)]; + recv(peer, inbound, sizeof(inbound), 0); + struct dbl_result result = {.version = DBL_VERSION, + .status = DBL_STATUS_OK, + .worker_uid = 2200, + .worker_pid = 42, + .start_ticks = 1, + .stage = DBL_STAGE_OUTPUT}; + memcpy(result.turn_id, "turn-1", 7); + if (truncated) + write(peer, &result, sizeof(result) / 2); + else { + result.reserved = 1; + write(peer, &result, sizeof(result)); + } + close(peer); + close(server); + struct dbl_result observed; + check(read(output[0], &observed, sizeof(observed)) == sizeof(observed) && + result_padding_zero(&observed) && + observed.status == DBL_STATUS_PRELAUNCH_FAILED && + observed.stage == DBL_STAGE_REQUEST && + observed.failure_class == DBL_FAILURE_PROTOCOL && + !observed.reserved, + "fixed protocol diagnostic"); + close(output[0]); + int status; + waitpid(child, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, + truncated ? "truncated result" : "reserved result"); +} diff --git a/src/runtime/native/engineBrokerLauncherIntegrationLauncher.inc b/src/runtime/native/engineBrokerLauncherIntegrationLauncher.inc new file mode 100644 index 0000000..8458226 --- /dev/null +++ b/src/runtime/native/engineBrokerLauncherIntegrationLauncher.inc @@ -0,0 +1,147 @@ +static void root_peer_rejects(void) { + int s = connect_socket(), p = sealed("prompt"), + c = sealed_bundle("provider.Token-1", "mcp.Token-2"); + struct dbl_request q = request(); + struct dbl_result r; + send_one(s, &q, p); + check(read(s, &r, sizeof(r)) == sizeof(r) && r.status == 1, + "fd count rejection"); + close(s); + s = connect_socket(); + send_request(s, &q, p, c); + check(read(s, &r, sizeof(r)) == sizeof(r) && r.status == 1, + "root peer rejection"); + close(s); + close(p); + close(c); +} +static void org_launcher_rejects(void) { + pid_t child = fork(); + if (!child) { + setgid(DBL_ORG_UID); + setuid(DBL_ORG_UID); + int s = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un a = {.sun_family = AF_UNIX}; + strcpy(a.sun_path, DBL_SOCKET); + _exit(connect(s, (void *)&a, sizeof(a)) < 0 ? 0 : 1); + } + int status; + waitpid(child, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, + "org launcher rejection"); +} +static void abi_v1_registration_rejects(void) { + pid_t child = fork(); + if (!child) { + setgid(DBL_BROKER_UID); + setuid(DBL_BROKER_UID); + int s = connect_socket(), p = sealed("prompt"), + c = sealed_bundle("provider.Token-1", "mcp.Token-2"); + struct dbl_request q = request(); + q.slot = 8; + struct dbl_result r; + send_request(s, &q, p, c); + _exit(read_all(s, &r, sizeof(r)) && + r.status == DBL_STATUS_PRELAUNCH_FAILED && + r.stage == DBL_STAGE_REGISTRATION && + r.failure_class == DBL_FAILURE_REGISTRATION + ? 0 + : 1); + } + int status; + waitpid(child, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, + "ABI v1 registration rejection"); +} +static void output_boundary_case(const char *provider, int overflow) { + int s = connect_socket(), p = sealed("prompt"), + c = sealed_bundle(provider, "mcp.Output-2"); + struct dbl_request q = request(); + struct dbl_result r; + send_request(s, &q, p, c); + check(read_all(s, &r, sizeof(r)) && result_padding_zero(&r), + "output boundary result"); + if (overflow) { + check(r.status == DBL_STATUS_OUTPUT_FAILED && + r.stage == DBL_STAGE_OUTPUT && + r.failure_class == DBL_FAILURE_OUTPUT_LIMIT && + r.output_length == 0 && r.worker_pid > 0 && + r.worker_uid == 2200 && r.start_ticks > 0, + "output overflow diagnostic"); + } else { + char *bytes = malloc(DBL_MAX_OUTPUT); + check(r.status == DBL_STATUS_OK && r.stage == DBL_STAGE_OUTPUT && + r.failure_class == DBL_FAILURE_NONE && + r.output_length == DBL_MAX_OUTPUT && + read_all(s, bytes, DBL_MAX_OUTPUT), + "exact output boundary"); + free(bytes); + } + char extra; + check(read(s, &extra, 1) == 0, "output boundary EOF"); + close(s); + close(p); + close(c); +} +static void org_cases(void) { + check(!setgid(DBL_BROKER_UID) && !setuid(DBL_BROKER_UID), "drop broker uid"); + client_case(); + client_input_reject(DBL_MAX_PROMPT + 1, 0); + client_input_reject(5, 1); + output_boundary_case("exact-output", 0); + output_boundary_case("overflow-output", 1); + int s = connect_socket(), p = sealed("prompt"), + c = sealed_bundle("provider.Token-1", "mcp.Token-2"); + struct dbl_request q = request(); + struct dbl_result r; + send_one(s, &q, p); + check(read(s, &r, sizeof(r)) == sizeof(r) && result_padding_zero(&r) && + r.status == DBL_STATUS_PRELAUNCH_FAILED && + r.stage == DBL_STAGE_REQUEST && + r.failure_class == DBL_FAILURE_PROTOCOL, + "fd count rejection"); + close(s); + s = connect_socket(); + send_request(s, &q, p, c); + check(read(s, &r, sizeof(r)) == sizeof(r) && result_padding_zero(&r) && + r.status == DBL_STATUS_OK && r.stage == DBL_STAGE_OUTPUT && + r.worker_uid == 2200 && r.output_length < DBL_MAX_OUTPUT, + "valid result"); + char *out = calloc(1, r.output_length + 1); + check(read(s, out, r.output_length) == (ssize_t)r.output_length && + strstr(out, "uid=2200") && strstr(out, "--always-approve") && + !strstr(out, "EVIL"), + "fixed worker boundary"); + free(out); + close(s); + q = request(); + strcpy(q.agent_id, "../../EVIL"); + s = connect_socket(); + send_request(s, &q, p, c); + check(read(s, &r, sizeof(r)) == sizeof(r) && + r.status == DBL_STATUS_PRELAUNCH_FAILED && + r.stage == DBL_STAGE_REQUEST && + r.failure_class == DBL_FAILURE_PROTOCOL, + "path injection"); + close(s); + q = request(); + q.slot = 2200; + s = connect_socket(); + send_request(s, &q, p, c); + check(read(s, &r, sizeof(r)) == sizeof(r) && + r.status == DBL_STATUS_PRELAUNCH_FAILED && + r.stage == DBL_STAGE_REGISTRATION && + r.failure_class == DBL_FAILURE_REGISTRATION, + "registration failure"); + close(s); + q = request(); + s = connect_socket(); + int sleepfd = sealed_bundle("sleep", "mcp.Sleep-2"); + send_request(s, &q, p, sleepfd); + close(s); + sleep(1); + char command[128]; + snprintf(command, sizeof(command), + "! pgrep -u 2200 fixture-worker >/dev/null"); + check(system(command) == 0, "disconnect cleanup"); +} diff --git a/src/runtime/native/engineBrokerLauncherIntegrationMain.inc b/src/runtime/native/engineBrokerLauncherIntegrationMain.inc new file mode 100644 index 0000000..0b816dd --- /dev/null +++ b/src/runtime/native/engineBrokerLauncherIntegrationMain.inc @@ -0,0 +1,68 @@ +int main(void) { + setbuf(stdout, NULL); + alarm(40); + check(sizeof(struct dbl_result) == DBL_RESULT_SIZE && + offsetof(struct dbl_result, stage) == DBL_RESULT_STAGE_OFFSET && + offsetof(struct dbl_result, failure_class) == + DBL_RESULT_FAILURE_CLASS_OFFSET && + offsetof(struct dbl_result, profile_applied) == + DBL_RESULT_PROFILE_APPLIED_OFFSET && + offsetof(struct dbl_result, reserved) == DBL_RESULT_RESERVED_OFFSET, + "result ABI"); + mkdir("/run/daimon-engine-broker", 0755); + mkdir("/etc/daimon-engine-broker", 0755); + mkdir("/tmp/worker-home", 0777); + mkdir("/tmp/workspace", 0777); + struct dbl_registration old = { + .version = 1, .slot = 8, .uid = 2200, .gid = 2200}; + strcpy(old.agent_id, "agent-1"); + strcpy(old.workspace, "/tmp/workspace"); + strcpy(old.home, "/tmp/worker-home"); + digest("/usr/local/bin/grok", old.executable_sha256); + struct dbl_registration r = { + .version = DBL_VERSION, .slot = 7, .uid = 2200, .gid = 2200}; + strcpy(r.agent_id, "agent-1"); + strcpy(r.workspace, "/tmp/workspace"); + strcpy(r.home, "/tmp/worker-home"); + digest("/usr/local/bin/grok", r.executable_sha256); + int f = open(DBL_REGISTRY, O_CREAT | O_TRUNC | O_WRONLY, 0600); + check(f >= 0 && write(f, &old, sizeof(old)) == sizeof(old), "v1 registry"); + close(f); + pid_t old_broker = fork(); + if (!old_broker) + execl("/opt/daimon/bin/daimon-engine-broker", "daimon-engine-broker", NULL); + check(old_broker > 0, "v1 broker fork"); + wait_launcher_ready(old_broker); + abi_v1_registration_rejects(); + kill(old_broker, SIGKILL); + waitpid(old_broker, NULL, 0); + unlink(DBL_SOCKET); + f = open(DBL_REGISTRY, O_TRUNC | O_WRONLY, 0600); + check(f >= 0 && write(f, &r, sizeof(r)) == sizeof(r), "v2 registry"); + close(f); + auth_adversarial_cases(); + puts("native-stage auth complete"); + pid_t broker = fork(); + if (!broker) + execl("/opt/daimon/bin/daimon-engine-broker", "daimon-engine-broker", NULL); + check(broker > 0, "broker fork"); + wait_launcher_ready(broker); + root_peer_rejects(); + org_launcher_rejects(); + pid_t org = fork(); + if (!org) { + org_cases(); + _exit(0); + } + int status; + waitpid(org, &status, 0); + kill(broker, SIGKILL); + waitpid(broker, 0, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, "org cases"); + puts("native-stage launcher complete"); + malformed_response_case(1); + malformed_response_case(0); + puts("native-stage client protocol complete"); + relay_cases(); + puts("native broker adversarial integration passed"); +} diff --git a/src/runtime/native/engineBrokerLauncherIntegrationRelay.inc b/src/runtime/native/engineBrokerLauncherIntegrationRelay.inc new file mode 100644 index 0000000..fd962b8 --- /dev/null +++ b/src/runtime/native/engineBrokerLauncherIntegrationRelay.inc @@ -0,0 +1,246 @@ +static int unix_listener(const char *path, mode_t mode) { + unlink(path); + int s = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un a = {.sun_family = AF_UNIX}; + strcpy(a.sun_path, path); + check(s >= 0 && !bind(s, (void *)&a, sizeof(a)) && !chmod(path, mode) && + !listen(s, 4), + "unix listener"); + return s; +} +static pid_t backend_once(unsigned delay_seconds) { + pid_t child = fork(); + if (!child) { + setgid(DBL_BROKER_UID); + setuid(DBL_BROKER_UID); + int s = unix_listener(DBL_BACKEND_SOCKET, 0600), + peer = accept(s, NULL, NULL); + unsigned char header[4], body[256]; + if (!read_all(peer, header, 4)) + _exit(31); + uint32_t n = ((uint32_t)header[0] << 24) | ((uint32_t)header[1] << 16) | + ((uint32_t)header[2] << 8) | header[3]; + if (n > sizeof(body) || !read_all(peer, body, n)) + _exit(32); + if (delay_seconds) + sleep(delay_seconds); + write(peer, header, 4); + write(peer, body, n); + shutdown(peer, SHUT_WR); + close(peer); + close(s); + _exit(0); + } + for (int i = 0; i < 50 && access(DBL_BACKEND_SOCKET, F_OK); i++) + usleep(20000); + return child; +} +static pid_t backend_eof(void) { + pid_t child = fork(); + if (!child) { + setgid(DBL_BROKER_UID); + setuid(DBL_BROKER_UID); + int s = unix_listener(DBL_BACKEND_SOCKET, 0600), + peer = accept(s, NULL, NULL); + char byte; + for (int attempt = 0; attempt < 3; attempt++) { + ssize_t observed = read(peer, &byte, 1); + if (observed == 0) + _exit(0); + if (observed > 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) + _exit(51); + } + _exit(51); + } + for (int i = 0; i < 50 && access(DBL_BACKEND_SOCKET, F_OK); i++) + usleep(20000); + return child; +} +static pid_t backend_wait(pid_t relay) { + pid_t child = fork(); + if (!child) { + setgid(DBL_BROKER_UID); + setuid(DBL_BROKER_UID); + int s = unix_listener(DBL_BACKEND_SOCKET, 0600), + peer = accept(s, NULL, NULL); + unsigned char frame[6]; + if (!read_all(peer, frame, sizeof(frame))) + _exit(61); + if (kill(relay, SIGKILL)) + _exit(62); + char byte; + _exit(read(peer, &byte, 1) <= 0 ? 0 : 63); + } + for (int i = 0; i < 50 && access(DBL_BACKEND_SOCKET, F_OK); i++) + usleep(20000); + return child; +} +static void org_relay_reject(const unsigned char *frame, size_t length) { + pid_t child = fork(); + if (!child) { + setgid(DBL_ORG_UID); + setuid(DBL_ORG_UID); + int s = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un a = {.sun_family = AF_UNIX}; + strcpy(a.sun_path, DBL_CONTROL_SOCKET); + if (connect(s, (void *)&a, sizeof(a)) || !write_all(s, frame, length)) + _exit(52); + char byte; + _exit(read(s, &byte, 1) <= 0 ? 0 : 53); + } + int status; + waitpid(child, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, "relay rejection"); +} +static void relay_identity(pid_t relay) { + char file[64], line[256]; + snprintf(file, sizeof(file), "/proc/%ld/status", (long)relay); + for (int attempt = 0; attempt < 50; attempt++) { + FILE *status = fopen(file, "r"); + uid_t uid = (uid_t)-1; + unsigned long long effective = 1, permitted = 1, ambient = 1, bounding = 1; + unsigned no_new_privs = 0; + if (status) { + while (fgets(line, sizeof(line), status)) { + if (sscanf(line, "Uid:\t%u", &uid) == 1) + continue; + if (sscanf(line, "CapEff:\t%llx", &effective) == 1) + continue; + if (sscanf(line, "CapPrm:\t%llx", &permitted) == 1) + continue; + if (sscanf(line, "CapAmb:\t%llx", &ambient) == 1) + continue; + if (sscanf(line, "CapBnd:\t%llx", &bounding) == 1) + continue; + sscanf(line, "NoNewPrivs:\t%u", &no_new_privs); + } + fclose(status); + } + if (uid == DBL_BROKER_UID && effective == 0 && permitted == 0 && + ambient == 0 && bounding == 0 && no_new_privs == 1) { + snprintf(file, sizeof(file), "/proc/%ld/limits", (long)relay); + FILE *limits = fopen(file, "r"); + int core_zero = 0; + if (limits) { + while (fgets(line, sizeof(line), limits)) + if (!strncmp(line, "Max core file size", 18) && + strstr(line, "0 0")) + core_zero = 1; + fclose(limits); + } + if (core_zero) + return; + } + usleep(20000); + } + check(0, "relay identity/caps"); +} +static void relay_requires_setpcap(void) { + unlink(DBL_CONTROL_SOCKET); + pid_t child = fork(); + if (!child) + execlp("setpriv", "setpriv", "--inh-caps=-all", "--ambient-caps=-all", + "--bounding-set=-all,+chown,+setuid,+setgid", "--", + "/opt/daimon/bin/daimon-engine-broker", "--relay", NULL); + int status; + waitpid(child, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 111, + "relay requires setpcap"); + unlink(DBL_CONTROL_SOCKET); +} +static void relay_cases(void) { + puts("native-stage relay start"); + chmod("/run/daimon-engine-broker", 0777); + relay_requires_setpcap(); + puts("native-stage relay caps complete"); + pid_t backend = backend_once(11), relay = fork(); + check(relay >= 0, "relay fork"); + if (!relay) + execlp("setpriv", "setpriv", "--inh-caps=-all", "--ambient-caps=-all", + "--bounding-set=-all,+chown,+setuid,+setgid,+setpcap", "--", + "/opt/daimon/bin/daimon-engine-broker", "--relay", NULL); + for (int i = 0; i < 50 && access(DBL_CONTROL_SOCKET, F_OK); i++) + usleep(20000); + struct stat control; + check(!lstat(DBL_CONTROL_SOCKET, &control) && S_ISSOCK(control.st_mode) && + control.st_uid == 0 && control.st_gid == DBL_ORG_UID && + (control.st_mode & 0777) == 0660, + "control socket authority"); + relay_identity(relay); + puts("native-stage relay identity complete"); + int spoof = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un a = {.sun_family = AF_UNIX}; + strcpy(a.sun_path, DBL_CONTROL_SOCKET); + check(!connect(spoof, (void *)&a, sizeof(a)), "spoof connect"); + unsigned char frame[] = {0, 0, 0, 2, '{', '}'}; + write(spoof, frame, sizeof(frame)); + check(read(spoof, frame, 1) <= 0, "org uid spoof rejection"); + close(spoof); + pid_t org = fork(); + if (!org) { + setgid(DBL_ORG_UID); + setuid(DBL_ORG_UID); + int s = socket(AF_UNIX, SOCK_STREAM, 0); + connect(s, (void *)&a, sizeof(a)); + unsigned char request_frame[] = {0, 0, 0, 2, '{', '}'}; + write(s, request_frame, sizeof(request_frame)); + shutdown(s, SHUT_WR); + unsigned char response[6]; + if (!read_all(s, response, sizeof(response)) || + memcmp(request_frame, response, sizeof(response))) + _exit(41); + if (read(s, response, 1) != 0) + _exit(42); + _exit(0); + } + int status; + waitpid(org, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, + "relay exact frame/half close"); + waitpid(backend, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, "backend relay"); + puts("native-stage relay idle success complete"); + unlink(DBL_BACKEND_SOCKET); + backend = backend_eof(); + unsigned char partial[] = {0, 0, 0, 2, '{'}; + org_relay_reject(partial, sizeof(partial)); + kill(backend, SIGKILL); + waitpid(backend, &status, 0); + puts("native-stage relay partial deadline complete"); + unlink(DBL_BACKEND_SOCKET); + backend = backend_eof(); + unsigned char oversized[] = {0, 16, 0, 1}; + org_relay_reject(oversized, sizeof(oversized)); + waitpid(backend, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, "oversized relay frame"); + puts("native-stage relay oversized complete"); + unlink(DBL_BACKEND_SOCKET); + int wrong = unix_listener(DBL_BACKEND_SOCKET, 0600); + org_relay_reject(frame, sizeof(frame)); + puts("native-stage relay wrong-backend complete"); + close(wrong); + unlink(DBL_BACKEND_SOCKET); + backend = backend_wait(relay); + org = fork(); + if (!org) { + setgid(DBL_ORG_UID); + setuid(DBL_ORG_UID); + int s = socket(AF_UNIX, SOCK_STREAM, 0); + connect(s, (void *)&a, sizeof(a)); + write_all(s, frame, sizeof(frame)); + char byte; + _exit(read(s, &byte, 1) <= 0 ? 0 : 63); + } + puts("native-stage relay death kill"); + check(wait_bounded(relay, &status), "relay bounded reap"); + puts("native-stage relay parent dead"); + check(wait_bounded(org, &status) && WIFEXITED(status) && + WEXITSTATUS(status) == 0, + "relay death propagates"); + puts("native-stage relay org dead"); + check(wait_bounded(backend, &status) && WIFEXITED(status) && + WEXITSTATUS(status) == 0, + "relay death closes backend"); + unlink(DBL_CONTROL_SOCKET); + unlink(DBL_BACKEND_SOCKET); +} diff --git a/src/runtime/native/engineBrokerLauncherMain.inc b/src/runtime/native/engineBrokerLauncherMain.inc new file mode 100644 index 0000000..6865308 --- /dev/null +++ b/src/runtime/native/engineBrokerLauncherMain.inc @@ -0,0 +1,52 @@ +int main(int argc, char **argv) { + int s, c; + pid_t handler; + struct sockaddr_un a = {.sun_family = AF_UNIX}; + mode_t old; + if (argc == 2 && strcmp(argv[1], "--client") == 0) + return client_mode() == 0 ? 0 : 111; + if (argc == 2 && strcmp(argv[1], "--auth-provider") == 0) + return auth_provider_mode() == 0 ? 0 : 111; + if (argc == 2 && strcmp(argv[1], "--relay") == 0) + return relay_mode() == 0 ? 0 : 111; + if (argc != 1 || geteuid() != 0) + return 111; + if (strlen(DBL_SOCKET) >= sizeof(a.sun_path)) + return 111; + memcpy(a.sun_path, DBL_SOCKET, sizeof(DBL_SOCKET)); + s = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (s < 0) + return 111; + unlink(DBL_SOCKET); + old = umask(0007); + if (bind(s, (struct sockaddr *)&a, + offsetof(struct sockaddr_un, sun_path) + strlen(a.sun_path) + 1) || + chown(DBL_SOCKET, 0, DBL_BROKER_UID) || chmod(DBL_SOCKET, 0660) || + listen(s, 16)) + die(); + umask(old); + for (;;) { + c = accept4(s, NULL, NULL, SOCK_CLOEXEC); + if (c < 0) { + if (errno == EINTR) + continue; + die(); + } + handler = fork(); + if (handler < 0) { + close(c); + continue; + } + if (handler == 0) { + if (prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() == 1) + die(); + close(s); + serve(c); + close(c); + _exit(0); + } + close(c); + while (waitpid(-1, NULL, WNOHANG) > 0) { + } + } +} diff --git a/src/runtime/native/engineBrokerLauncherModes.inc b/src/runtime/native/engineBrokerLauncherModes.inc new file mode 100644 index 0000000..ac78544 --- /dev/null +++ b/src/runtime/native/engineBrokerLauncherModes.inc @@ -0,0 +1,251 @@ +static int client_mode(void) { + struct dbl_request request; + uint32_t prompt_length = 0, capability_length = 0; + unsigned char *prompt = NULL, capability[DBL_MAX_CAPABILITY_BUNDLE] = {0}, + output[DBL_MAX_OUTPUT] = {0}, extra; + struct dbl_result result; + struct sockaddr_un a = {.sun_family = AF_UNIX}; + int s = -1, p = -1, c = -1, ok = -1; + if (getuid() != DBL_BROKER_UID) + goto done; + if (full_read(STDIN_FILENO, &request, sizeof(request)) || + full_read(STDIN_FILENO, &prompt_length, sizeof(prompt_length)) || + !prompt_length || prompt_length > DBL_MAX_PROMPT || + (prompt = malloc(prompt_length)) == NULL || + full_read(STDIN_FILENO, prompt, prompt_length) || + full_read(STDIN_FILENO, &capability_length, sizeof(capability_length)) || + !capability_length || capability_length > DBL_MAX_CAPABILITY_BUNDLE || + full_read(STDIN_FILENO, capability, capability_length) || + read(STDIN_FILENO, &extra, 1) != 0) + goto done; + if (request.version != DBL_VERSION || + !safe_component(request.request_id, sizeof(request.request_id)) || + !safe_component(request.turn_id, sizeof(request.turn_id)) || + !safe_component(request.agent_id, sizeof(request.agent_id)) || + !safe_component(request.wake_id, sizeof(request.wake_id))) + goto done; + p = client_memfd("daimon-prompt", prompt, prompt_length); + c = client_memfd("daimon-capability", capability, capability_length); + erase(prompt, prompt_length); + erase(capability, sizeof(capability)); + if (p < 0 || c < 0 || + (s = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0)) < 0) + goto done; + memcpy(a.sun_path, DBL_SOCKET, sizeof(DBL_SOCKET)); + if (connect(s, (struct sockaddr *)&a, + offsetof(struct sockaddr_un, sun_path) + strlen(a.sun_path) + + 1) || + client_send(s, &request, p, c) || full_read(s, &result, sizeof(result)) || + !closed_result(&result, request.turn_id) || + full_read(s, output, result.output_length) || read(s, &extra, 1) != 0) { + struct dbl_result failure; + memset(&failure, 0, sizeof(failure)); + failure.version = DBL_VERSION; + failure.status = DBL_STATUS_PRELAUNCH_FAILED; + failure.exit_code = -1; + failure.stage = DBL_STAGE_REQUEST; + failure.failure_class = DBL_FAILURE_PROTOCOL; + memcpy(failure.turn_id, request.turn_id, sizeof(failure.turn_id)); + if (full_write(STDOUT_FILENO, &failure, sizeof(failure)) == 0) + ok = 0; + goto done; + } + if (full_write(STDOUT_FILENO, &result, sizeof(result)) || + full_write(STDOUT_FILENO, output, result.output_length)) + goto done; + ok = 0; +done: + if (prompt) { + erase(prompt, prompt_length); + free(prompt); + } + erase(capability, sizeof(capability)); + erase(output, sizeof(output)); + erase(&request, sizeof(request)); + prompt_length = 0; + capability_length = 0; + if (s >= 0) + close(s); + if (p >= 0) + close(p); + if (c >= 0) + close(c); + return ok; +} +static int auth_provider_mode(void) { + unsigned char provider[DBL_MAX_TOKEN + 1] = {0}; + int ok = -1; + if (getuid() < 2200 || capability_bundle(4, provider, NULL)) + goto done; + const char prefix[] = "{\"access_token\":\"", + suffix[] = "\",\"expires_in\":600}\n"; + size_t n = strlen((char *)provider); + if (full_write(STDOUT_FILENO, prefix, sizeof(prefix) - 1) || + full_write(STDOUT_FILENO, provider, n) || + full_write(STDOUT_FILENO, suffix, sizeof(suffix) - 1)) + goto done; + ok = 0; +done: + erase(provider, sizeof(provider)); + return ok; +} + +static int relay_frame(int from, int to) { + unsigned char header[4], buffer[8192]; + if (full_read(from, header, sizeof(header))) + return 0; + uint32_t length = ((uint32_t)header[0] << 24) | ((uint32_t)header[1] << 16) | + ((uint32_t)header[2] << 8) | header[3]; + if (!length || length > DBL_MAX_CONTROL_FRAME) + return -1; + if (full_write(to, header, sizeof(header))) + return -1; + while (length) { + size_t chunk = length < sizeof(buffer) ? length : sizeof(buffer); + if (full_read(from, buffer, chunk) || full_write(to, buffer, chunk)) + return -1; + length -= (uint32_t)chunk; + } + erase(buffer, sizeof(buffer)); + return 1; +} +static int backend_connect(void) { + struct stat s; + struct sockaddr_un a = {.sun_family = AF_UNIX}; + struct ucred peer; + socklen_t size = sizeof(peer); + if (lstat(DBL_BACKEND_SOCKET, &s) || !S_ISSOCK(s.st_mode) || + s.st_uid != DBL_BROKER_UID || (s.st_mode & 077) != 0) + return -1; + int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + memcpy(a.sun_path, DBL_BACKEND_SOCKET, sizeof(DBL_BACKEND_SOCKET)); + if (connect(fd, (struct sockaddr *)&a, + offsetof(struct sockaddr_un, sun_path) + strlen(a.sun_path) + + 1) || + getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &peer, &size) || + peer.uid != DBL_BROKER_UID) { + close(fd); + return -1; + } + return fd; +} +static void relay_connection(int org) { + struct ucred peer; + socklen_t size = sizeof(peer); + if (getsockopt(org, SOL_SOCKET, SO_PEERCRED, &peer, &size) || + peer.uid != DBL_ORG_UID) + return; + int backend = backend_connect(); + if (backend < 0) + return; + struct timeval deadline = {.tv_sec = 10, .tv_usec = 0}; + if (setsockopt(org, SOL_SOCKET, SO_RCVTIMEO, &deadline, sizeof(deadline)) || + setsockopt(org, SOL_SOCKET, SO_SNDTIMEO, &deadline, sizeof(deadline)) || + setsockopt(backend, SOL_SOCKET, SO_RCVTIMEO, &deadline, + sizeof(deadline)) || + setsockopt(backend, SOL_SOCKET, SO_SNDTIMEO, &deadline, + sizeof(deadline))) { + close(backend); + return; + } + struct pollfd descriptors[2] = {{org, POLLIN | POLLHUP | POLLERR, 0}, + {backend, POLLIN | POLLHUP | POLLERR, 0}}; + int open[2] = {1, 1}; + while (open[0] || open[1]) { + int ready = poll(descriptors, 2, -1); + if (ready < 0) { + if (errno == EINTR) + continue; + break; + } + for (int i = 0; i < 2; i++) { + if (!open[i]) + continue; + int other = 1 - i, from = i ? backend : org, to = i ? org : backend; + if (descriptors[i].revents & POLLIN) { + int result = relay_frame(from, to); + if (result < 1) { + open[i] = 0; + shutdown(to, SHUT_WR); + if (result < 0) { + open[other] = 0; + shutdown(from, SHUT_RDWR); + shutdown(to, SHUT_RDWR); + } + } + } + if (descriptors[i].revents & (POLLHUP | POLLERR)) { + open[i] = 0; + shutdown(to, SHUT_WR); + } + } + } + close(backend); +} +static int relay_mode(void) { + struct sockaddr_un a = {.sun_family = AF_UNIX}; + struct rlimit z = {0, 0}; + if (geteuid() != 0) + return -1; + int listener = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (listener < 0) + return -1; + unlink(DBL_CONTROL_SOCKET); + memcpy(a.sun_path, DBL_CONTROL_SOCKET, sizeof(DBL_CONTROL_SOCKET)); + mode_t old = umask(0007); + if (bind(listener, (struct sockaddr *)&a, + offsetof(struct sockaddr_un, sun_path) + strlen(a.sun_path) + 1) || + chown(DBL_CONTROL_SOCKET, 0, DBL_ORG_UID) || + chmod(DBL_CONTROL_SOCKET, 0660) || listen(listener, 32)) { + umask(old); + close(listener); + return -1; + } + umask(old); + if (setrlimit(RLIMIT_CORE, &z)) + die(); + drop_bounding_caps(); + if (setgroups(0, NULL) || prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0) || + setresgid(DBL_BROKER_UID, DBL_BROKER_UID, DBL_BROKER_UID) || + setresuid(DBL_BROKER_UID, DBL_BROKER_UID, DBL_BROKER_UID)) + die(); + zero_caps(); + if (prctl(PR_SET_KEEPCAPS, 0, 0, 0, 0) || + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) + die(); + for (int cap = 0; cap < 256; cap++) { + int present = prctl(PR_CAPBSET_READ, cap, 0, 0, 0); + if (present < 0) { + if (errno == EINVAL) + break; + die(); + } + if (present) + die(); + } + for (;;) { + int client = accept4(listener, NULL, NULL, SOCK_CLOEXEC); + if (client < 0) { + if (errno == EINTR) + continue; + break; + } + pid_t child = fork(); + if (child == 0) { + if (prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() == 1) + die(); + close(listener); + relay_connection(client); + close(client); + _exit(0); + } + close(client); + while (waitpid(-1, NULL, WNOHANG) > 0) { + } + } + close(listener); + return -1; +} + diff --git a/src/runtime/native/engineBrokerLauncherServer.inc b/src/runtime/native/engineBrokerLauncherServer.inc new file mode 100644 index 0000000..a6cb347 --- /dev/null +++ b/src/runtime/native/engineBrokerLauncherServer.inc @@ -0,0 +1,217 @@ +static void supervise(int client, pid_t pid, int output, + struct dbl_result *out) { + unsigned char bytes[DBL_MAX_OUTPUT + 1] = {0}; + size_t used = 0; + int status = 0, done = 0, disconnected = 0, output_limited = 0, + wait_failed = 0; + struct pollfd p[2] = {{client, POLLIN | POLLHUP | POLLERR, 0}, + {output, POLLIN | POLLHUP | POLLERR, 0}}; + while (!done) { + int n = poll(p, 2, 250); + if (n < 0 && errno != EINTR) { + disconnected = 1; + wait_failed = 1; + } + if (p[0].revents & (POLLHUP | POLLERR)) + disconnected = 1; + if (p[0].revents & POLLIN) { + char b; + (void)recv(client, &b, 1, MSG_PEEK | MSG_DONTWAIT); + disconnected = 1; + } + if (p[1].revents & POLLIN) { + ssize_t got = read(output, bytes + used, sizeof(bytes) - used); + if (got > 0) + used += (size_t)got; + if (used > DBL_MAX_OUTPUT) { + output_limited = 1; + p[1].events = 0; + out->status = DBL_STATUS_OUTPUT_FAILED; + out->stage = DBL_STAGE_OUTPUT; + out->failure_class = DBL_FAILURE_OUTPUT_LIMIT; + } + } + if (disconnected || output_limited) + kill(-pid, SIGKILL); + pid_t w = waitpid(pid, &status, WNOHANG); + if (w == pid) + done = 1; + else if (w < 0 && errno != EINTR) { + done = 1; + wait_failed = 1; + } + } + kill(-pid, SIGKILL); + while (!output_limited && used < sizeof(bytes)) { + ssize_t got = read(output, bytes + used, sizeof(bytes) - used); + if (got <= 0) + break; + used += (size_t)got; + } + if (WIFEXITED(status)) + out->exit_code = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) + out->term_signal = WTERMSIG(status); + if (output_limited) { + out->status = DBL_STATUS_OUTPUT_FAILED; + out->stage = DBL_STAGE_OUTPUT; + out->failure_class = DBL_FAILURE_OUTPUT_LIMIT; + } else if (wait_failed) { + out->status = DBL_STATUS_WORKER_FAILED; + out->stage = DBL_STAGE_WAIT; + out->failure_class = DBL_FAILURE_WAIT; + } else if (disconnected) { + out->status = DBL_STATUS_CANCELLED; + out->stage = DBL_STAGE_WAIT; + out->failure_class = DBL_FAILURE_CANCELLED; + } else if (out->exit_code != 0 || out->term_signal != 0) { + out->status = DBL_STATUS_WORKER_FAILED; + out->stage = DBL_STAGE_WAIT; + out->failure_class = DBL_FAILURE_EXEC; + } else { + out->status = DBL_STATUS_OK; + out->stage = DBL_STAGE_OUTPUT; + out->failure_class = DBL_FAILURE_NONE; + out->output_length = (uint32_t)used; + } + if (out->status != DBL_STATUS_OK) { + out->output_length = 0; + erase(bytes, sizeof(bytes)); + } + if (!disconnected) { + full_write(client, out, sizeof(*out)); + if (out->status == DBL_STATUS_OK) + full_write(client, bytes, used); + } + erase(bytes, sizeof(bytes)); +} + +static void serve(int client) { + struct ucred peer; + socklen_t pl = sizeof(peer); + struct dbl_request q = {0}; + struct dbl_registration r; + struct dbl_result out; + memset(&out, 0, sizeof(out)); + out.version = DBL_VERSION; + out.status = DBL_STATUS_PRELAUNCH_FAILED; + out.exit_code = -1; + out.stage = DBL_STAGE_PEER; + out.failure_class = DBL_FAILURE_PEER; + int fds[2] = {-1, -1}, pipes[2] = {-1, -1}, exe = -1; + uint32_t launch_failure = 0; + uint64_t launch_start_ticks = 0; + pid_t pid; + if (getsockopt(client, SOL_SOCKET, SO_PEERCRED, &peer, &pl) || + peer.uid != DBL_BROKER_UID) + goto done; + out.stage = DBL_STAGE_REQUEST; + out.failure_class = DBL_FAILURE_PROTOCOL; + if (receive_request(client, &q, fds) || q.version != DBL_VERSION || + !safe_component(q.request_id, sizeof(q.request_id)) || + !safe_component(q.turn_id, sizeof(q.turn_id)) || + !safe_component(q.agent_id, sizeof(q.agent_id)) || + !safe_component(q.wake_id, sizeof(q.wake_id)) || + !sealed_regular(fds[0], DBL_MAX_PROMPT) || + !sealed_regular(fds[1], DBL_MAX_CAPABILITY_BUNDLE)) + goto done; + memcpy(out.turn_id, q.turn_id, sizeof(out.turn_id)); + out.stage = DBL_STAGE_REGISTRATION; + out.failure_class = DBL_FAILURE_REGISTRATION; + if (load_registration(q.slot, &r) || !valid_registration(&r, &q)) + goto done; + out.stage = DBL_STAGE_EXECUTABLE; + out.failure_class = DBL_FAILURE_EXECUTABLE; + if ((exe = executable_fd(&r)) < 0) + goto done; + out.stage = DBL_STAGE_EXEC; + out.failure_class = DBL_FAILURE_EXEC; + if (pipe2(pipes, O_CLOEXEC | O_NONBLOCK)) + goto done; + pid = launch(&r, exe, fds[0], fds[1], pipes[1], &launch_failure, + &launch_start_ticks); + if (pid < 1) + goto done; + close(pipes[1]); + pipes[1] = -1; + out.status = DBL_STATUS_OK; + out.worker_pid = pid; + out.worker_uid = r.uid; + out.start_ticks = launch_start_ticks; + supervise(client, pid, pipes[0], &out); + goto cleanup; +done: + (void)launch_failure; + full_write(client, &out, sizeof(out)); +cleanup: + for (size_t i = 0; i < 2; i++) { + if (fds[i] >= 0) + close(fds[i]); + if (pipes[i] >= 0) + close(pipes[i]); + } + if (exe >= 0) + close(exe); +} + +static int client_memfd(const char *name, const unsigned char *bytes, + uint32_t length) { + int fd = + (int)syscall(SYS_memfd_create, name, MFD_ALLOW_SEALING | MFD_CLOEXEC); + if (fd < 0 || full_write(fd, bytes, length) || lseek(fd, 0, SEEK_SET) < 0 || + fcntl(fd, F_ADD_SEALS, + F_SEAL_WRITE | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_SEAL)) { + if (fd >= 0) + close(fd); + return -1; + } + return fd; +} +static int client_send(int socket_fd, const struct dbl_request *r, int prompt, + int capability) { + char control[CMSG_SPACE(sizeof(int) * 2)] = {0}; + int fds[2] = {prompt, capability}; + struct iovec io = {(void *)r, sizeof(*r)}; + struct msghdr m = {.msg_iov = &io, + .msg_iovlen = 1, + .msg_control = control, + .msg_controllen = sizeof(control)}; + struct cmsghdr *c = CMSG_FIRSTHDR(&m); + c->cmsg_level = SOL_SOCKET; + c->cmsg_type = SCM_RIGHTS; + c->cmsg_len = CMSG_LEN(sizeof(fds)); + memcpy(CMSG_DATA(c), fds, sizeof(fds)); + return sendmsg(socket_fd, &m, MSG_NOSIGNAL) == (ssize_t)sizeof(*r) ? 0 : -1; +} +static int closed_result(const struct dbl_result *r, const char turn_id[65]) { + if (r->version != DBL_VERSION || r->reserved || r->profile_applied > 1 || + r->stage > DBL_STAGE_ATTESTATION || + r->failure_class > DBL_FAILURE_ATTESTATION_PROFILE_INVALID || + r->output_length > DBL_MAX_OUTPUT || memcmp(r->turn_id, turn_id, 65)) + return 0; + if (r->status == DBL_STATUS_OK) + return r->stage == DBL_STAGE_OUTPUT && + r->failure_class == DBL_FAILURE_NONE && r->profile_applied == 0 && + r->worker_pid > 0 && r->worker_uid >= 2200 && r->start_ticks && + r->exit_code == 0 && r->term_signal == 0; + if (r->status == DBL_STATUS_PRELAUNCH_FAILED) + return r->stage >= DBL_STAGE_PEER && r->stage <= DBL_STAGE_EXEC && + r->failure_class >= DBL_FAILURE_PEER && + r->failure_class <= DBL_FAILURE_EXEC && r->worker_pid == 0 && + r->worker_uid == 0 && r->start_ticks == 0 && r->output_length == 0; + if (r->status == DBL_STATUS_WORKER_FAILED) + return r->stage == DBL_STAGE_WAIT && + (r->failure_class == DBL_FAILURE_EXEC || + r->failure_class == DBL_FAILURE_WAIT) && + r->worker_pid > 0 && r->worker_uid >= 2200 && r->start_ticks && + r->output_length == 0; + if (r->status == DBL_STATUS_OUTPUT_FAILED) + return r->stage == DBL_STAGE_OUTPUT && + r->failure_class == DBL_FAILURE_OUTPUT_LIMIT && r->worker_pid > 0 && + r->worker_uid >= 2200 && r->start_ticks && r->output_length == 0; + if (r->status == DBL_STATUS_CANCELLED) + return r->stage == DBL_STAGE_WAIT && + r->failure_class == DBL_FAILURE_CANCELLED && r->worker_pid > 0 && + r->worker_uid >= 2200 && r->start_ticks && r->output_length == 0; + return 0; +} diff --git a/src/runtime/native/fixtureWorker.c b/src/runtime/native/fixtureWorker.c new file mode 100644 index 0000000..6baaad7 --- /dev/null +++ b/src/runtime/native/fixtureWorker.c @@ -0,0 +1,8 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include "engineBrokerLauncher.h" +int main(int argc,char**argv){unsigned char bundle[8200]={0};ssize_t size=pread(4,bundle,sizeof(bundle),0);if(size<6)return 21;size_t offset=0;unsigned provider_length=bundle[0]|(bundle[1]<<8);offset=2;if(offset+provider_length+2>(size_t)size)return 21;char provider[4097]={0};memcpy(provider,bundle+offset,provider_length);offset+=provider_length;unsigned mcp_length=bundle[offset]|(bundle[offset+1]<<8);offset+=2;if(offset+mcp_length!=(size_t)size)return 21;char mcp[4097]={0};memcpy(mcp,bundle+offset,mcp_length);memset(bundle,0,sizeof(bundle));int exact=!strcmp(provider,"exact-output"),overflow=!strcmp(provider,"overflow-output");if(!strcmp(provider,"sleep"))sleep(30);if(!getenv("DAIMON_MCP_CAPABILITY")||strcmp(getenv("DAIMON_MCP_CAPABILITY"),mcp)||strstr(getenv("DAIMON_MCP_CAPABILITY"),provider))return 24;int output[2];if(pipe(output))return 22;pid_t child=fork();if(!child){dup2(output[1],1);close(output[0]);execl("/opt/daimon/bin/daimon-engine-broker","daimon-engine-broker","--auth-provider",NULL);_exit(127);}close(output[1]);char auth[8192]={0};ssize_t got=read(output[0],auth,sizeof(auth)-1);close(output[0]);int status;waitpid(child,&status,0);char expected[8192];snprintf(expected,sizeof(expected),"{\"access_token\":\"%s\",\"expires_in\":600}\n",provider);memset(provider,0,sizeof(provider));memset(mcp,0,sizeof(mcp));if(!WIFEXITED(status)||WEXITSTATUS(status)||got<1||strcmp(auth,expected))return 23;memset(auth,0,sizeof(auth));memset(expected,0,sizeof(expected));if(exact||overflow){size_t count=DBL_MAX_OUTPUT+(size_t)overflow;for(size_t i=0;i Date: Fri, 28 Aug 2026 19:41:39 +0200 Subject: [PATCH 05/24] feat(broker): enforce durable Grok turn authority --- src/runtime/engineBrokerCapabilities.test.ts | 15 +++++++ src/runtime/engineBrokerCapabilities.ts | 29 ++++++++++++++ src/runtime/engineBrokerControlClient.ts | 13 ++++++ .../engineBrokerCredentialJournal.test.ts | 10 +++++ src/runtime/engineBrokerCredentialJournal.ts | 15 +++++++ src/runtime/engineBrokerMcpFacade.test.ts | 10 +++++ src/runtime/engineBrokerMcpFacade.ts | 10 +++++ src/runtime/engineBrokerService.test.ts | 40 +++++++++++++++++++ src/runtime/engineBrokerService.ts | 25 ++++++++++++ src/runtime/engineBrokerServiceCli.test.ts | 15 +++++++ src/runtime/engineBrokerServiceCli.ts | 26 ++++++++++++ src/runtime/engineBrokerSingleflight.test.ts | 13 ++++++ src/runtime/engineBrokerSingleflight.ts | 18 +++++++++ src/runtime/engineBrokerTurnRegistry.test.ts | 22 ++++++++++ src/runtime/engineBrokerTurnRegistry.ts | 34 ++++++++++++++++ .../grokBrokerCredentialAuthority.test.ts | 18 +++++++++ src/runtime/grokBrokerCredentialAuthority.ts | 36 +++++++++++++++++ .../grokBrokerCredentialReader.test.ts | 11 +++++ src/runtime/grokBrokerCredentialReader.ts | 17 ++++++++ src/runtime/grokBrokerProxy.test.ts | 27 +++++++++++++ src/runtime/grokBrokerProxy.ts | 29 ++++++++++++++ src/runtime/grokBrokerProxyRequest.test.ts | 11 +++++ src/runtime/grokBrokerProxyRequest.ts | 16 ++++++++ src/runtime/grokBrokerRealmLease.test.ts | 9 +++++ src/runtime/grokBrokerRealmLease.ts | 11 +++++ src/runtime/grokBrokerRefresh.test.ts | 14 +++++++ src/runtime/grokBrokerRefresh.ts | 10 +++++ src/runtime/grokBrokerWorkerConfig.test.ts | 8 ++++ src/runtime/grokBrokerWorkerConfig.ts | 17 ++++++++ src/runtime/grokEngineBroker.ts | 31 ++++++++++++++ src/runtime/grokWorkerAttestation.test.ts | 16 ++++++++ src/runtime/grokWorkerAttestation.ts | 15 +++++++ 32 files changed, 591 insertions(+) create mode 100644 src/runtime/engineBrokerCapabilities.test.ts create mode 100644 src/runtime/engineBrokerCapabilities.ts create mode 100644 src/runtime/engineBrokerControlClient.ts create mode 100644 src/runtime/engineBrokerCredentialJournal.test.ts create mode 100644 src/runtime/engineBrokerCredentialJournal.ts create mode 100644 src/runtime/engineBrokerMcpFacade.test.ts create mode 100644 src/runtime/engineBrokerMcpFacade.ts create mode 100644 src/runtime/engineBrokerService.test.ts create mode 100644 src/runtime/engineBrokerService.ts create mode 100644 src/runtime/engineBrokerServiceCli.test.ts create mode 100644 src/runtime/engineBrokerServiceCli.ts create mode 100644 src/runtime/engineBrokerSingleflight.test.ts create mode 100644 src/runtime/engineBrokerSingleflight.ts create mode 100644 src/runtime/engineBrokerTurnRegistry.test.ts create mode 100644 src/runtime/engineBrokerTurnRegistry.ts create mode 100644 src/runtime/grokBrokerCredentialAuthority.test.ts create mode 100644 src/runtime/grokBrokerCredentialAuthority.ts create mode 100644 src/runtime/grokBrokerCredentialReader.test.ts create mode 100644 src/runtime/grokBrokerCredentialReader.ts create mode 100644 src/runtime/grokBrokerProxy.test.ts create mode 100644 src/runtime/grokBrokerProxy.ts create mode 100644 src/runtime/grokBrokerProxyRequest.test.ts create mode 100644 src/runtime/grokBrokerProxyRequest.ts create mode 100644 src/runtime/grokBrokerRealmLease.test.ts create mode 100644 src/runtime/grokBrokerRealmLease.ts create mode 100644 src/runtime/grokBrokerRefresh.test.ts create mode 100644 src/runtime/grokBrokerRefresh.ts create mode 100644 src/runtime/grokBrokerWorkerConfig.test.ts create mode 100644 src/runtime/grokBrokerWorkerConfig.ts create mode 100644 src/runtime/grokEngineBroker.ts create mode 100644 src/runtime/grokWorkerAttestation.test.ts create mode 100644 src/runtime/grokWorkerAttestation.ts diff --git a/src/runtime/engineBrokerCapabilities.test.ts b/src/runtime/engineBrokerCapabilities.test.ts new file mode 100644 index 0000000..af9835f --- /dev/null +++ b/src/runtime/engineBrokerCapabilities.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { EngineBrokerCapabilities } from "./engineBrokerCapabilities.js"; + +test("broker capabilities are opaque, scoped, bounded and revocable", () => { + const capabilities = new EngineBrokerCapabilities(); const token = capabilities.issue("agent-a", "turn-a", 60_000, 2); + assert.equal(token.length >= 40, true); assert.equal(capabilities.authorize("agent-a", "turn-a", token), true); + assert.equal(capabilities.authorize("agent-b", "turn-a", token), false); assert.equal(capabilities.authorize("agent-a", "turn-a", "wrong"), false); + assert.equal(capabilities.authorize("agent-a", "turn-a", token), true); assert.equal(capabilities.authorize("agent-a", "turn-a", token), false); + capabilities.revoke("turn-a"); assert.equal(capabilities.authorize("agent-a", "turn-a", token), false); +}); +test("proxy resolves scope from opaque token without caller identity", () => { + const capabilities = new EngineBrokerCapabilities(); const token = capabilities.issue("agent-a", "turn-a"); + assert.deepEqual(capabilities.authorizeToken(token), { agentId: "agent-a", turnId: "turn-a" }); +}); diff --git a/src/runtime/engineBrokerCapabilities.ts b/src/runtime/engineBrokerCapabilities.ts new file mode 100644 index 0000000..0bbd102 --- /dev/null +++ b/src/runtime/engineBrokerCapabilities.ts @@ -0,0 +1,29 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +type Grant = Readonly<{ agentId: string; turnId: string; digest: Buffer; expiresAt: number; maxRequests: number }>; + +export class EngineBrokerCapabilities { + private readonly grants = new Map(); + issue(agentId: string, turnId: string, ttlMs = 15 * 60_000, maxRequests = 64): string { + if (ttlMs < 1 || maxRequests < 1) throw new TypeError("invalid broker capability policy"); + const token = randomBytes(32).toString("base64url"); + this.grants.set(turnId, { agentId, turnId, digest: hash(token), expiresAt: Date.now() + ttlMs, maxRequests, requests: 0 }); + return token; + } + authorize(agentId: string, turnId: string, token: string): boolean { + const grant = this.grants.get(turnId); const candidate = hash(token); + if (grant === undefined || grant.agentId !== agentId || grant.expiresAt <= Date.now() || grant.requests >= grant.maxRequests || !timingSafeEqual(grant.digest, candidate)) return false; + grant.requests += 1; return true; + } + authorizeToken(token: string): Readonly<{ agentId: string; turnId: string }> | undefined { + const candidate = hash(token); + for (const grant of this.grants.values()) { + if (grant.expiresAt <= Date.now() || grant.requests >= grant.maxRequests || !timingSafeEqual(grant.digest, candidate)) continue; + grant.requests += 1; return { agentId: grant.agentId, turnId: grant.turnId }; + } + return undefined; + } + inspectToken(token:string):Readonly<{agentId:string;turnId:string}>|undefined{const candidate=hash(token);for(const grant of this.grants.values()){if(grant.expiresAt>Date.now()&&grant.requests createHash("sha256").update(value).digest(); diff --git a/src/runtime/engineBrokerControlClient.ts b/src/runtime/engineBrokerControlClient.ts new file mode 100644 index 0000000..7136132 --- /dev/null +++ b/src/runtime/engineBrokerControlClient.ts @@ -0,0 +1,13 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createConnection } from "node:net"; +import { encodeEngineBrokerFrame,EngineBrokerFrameDecoder,parseEngineBrokerResponse } from "./engineBrokerProtocol.js"; + +export interface EngineBrokerTurnClient { turn(agentId:string,wakeId:string,prompt:string,mcpEndpoint:string,signal?:AbortSignal):Promise; } +export class EngineBrokerControlClient implements EngineBrokerTurnClient { + constructor(private readonly socketPath="/run/daimon-engine-broker/control.sock"){} + async ready():Promise{const requestId=randomUUID(),socket=createConnection({path:this.socketPath}),decoder=new EngineBrokerFrameDecoder();await new Promise((resolve,reject)=>{let settled=false;const fail=()=>{if(settled)return;settled=true;socket.destroy();reject(new Error("engine broker unavailable"));};socket.once("error",fail);socket.once("close",fail);socket.once("connect",()=>socket.write(encodeEngineBrokerFrame({version:"noopolis.daimon.engine-broker.v1",kind:"health",requestId})));socket.on("data",(chunk)=>{try{for(const value of decoder.push(chunk)){const response=parseEngineBrokerResponse(value);if(response.kind!=="ready"||response.requestId!==requestId||settled)throw new Error();settled=true;socket.destroy();resolve();}}catch{fail();}});});} + async turn(agentId:string,wakeId:string,prompt:string,mcpEndpoint:string,signal?:AbortSignal):Promise{ + const turnId=createHash("sha256").update(`${agentId}\0${wakeId}`).digest("hex"),requestId=randomUUID();const request={version:"noopolis.daimon.engine-broker.v1",kind:"start_turn",requestId,turnId,agentId,wakeId,prompt,mcpEndpoint} as const;const socket=createConnection({path:this.socketPath});const decoder=new EngineBrokerFrameDecoder(); + return new Promise((resolve,reject)=>{let accepted=false,settled=false;const fail=()=>{if(settled)return;settled=true;cleanup();reject(new Error("engine broker unavailable"));};const cleanup=()=>{signal?.removeEventListener("abort",abort);socket.destroy();};const abort=()=>fail();signal?.addEventListener("abort",abort,{once:true});if(signal?.aborted)return abort();socket.once("connect",()=>socket.write(encodeEngineBrokerFrame(request)));socket.on("data",(chunk)=>{try{for(const value of decoder.push(chunk)){const response=parseEngineBrokerResponse(value);if(response.kind==="ready"||response.requestId!==requestId||response.turnId!==turnId)throw new Error();if(response.kind==="accepted"){if(accepted)throw new Error();accepted=true;continue;}if(!accepted||settled)throw new Error();settled=true;cleanup();if(response.kind==="completed")resolve(response.text);else reject(new Error(response.diagnostic ? `engine broker turn failed (${response.diagnostic.stage}/${response.diagnostic.failureClass}; exit=${response.diagnostic.exitCode}; signal=${response.diagnostic.termSignal})` : "engine broker turn failed"));}}catch{fail();}});socket.once("error",fail);socket.once("close",()=>{if(!settled)fail();});}); + } +} diff --git a/src/runtime/engineBrokerCredentialJournal.test.ts b/src/runtime/engineBrokerCredentialJournal.test.ts new file mode 100644 index 0000000..63c7c46 --- /dev/null +++ b/src/runtime/engineBrokerCredentialJournal.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseBrokerCredentialJournal, recoverBrokerCredentialJournal } from "./engineBrokerCredentialJournal.js"; +const a = "a".repeat(64); const b = "b".repeat(64); +test("credential crash recovery distinguishes pre and post replacement", () => { + const refreshing = parseBrokerCredentialJournal({ version: "noopolis.daimon.broker-credential-journal.v1", state: "refreshing", generation: 3, sourceDigest: a }); + assert.equal(recoverBrokerCredentialJournal(refreshing, a), "ready"); assert.equal(recoverBrokerCredentialJournal(refreshing, b), "stale"); + const promoted = parseBrokerCredentialJournal({ ...refreshing, state: "promoted", promotedDigest: b }); + assert.equal(recoverBrokerCredentialJournal(promoted, b), "ready"); assert.equal(recoverBrokerCredentialJournal(promoted, a), "stale"); +}); diff --git a/src/runtime/engineBrokerCredentialJournal.ts b/src/runtime/engineBrokerCredentialJournal.ts new file mode 100644 index 0000000..246f049 --- /dev/null +++ b/src/runtime/engineBrokerCredentialJournal.ts @@ -0,0 +1,15 @@ +export type BrokerCredentialJournal = Readonly<{ version: "noopolis.daimon.broker-credential-journal.v1"; state: "refreshing" | "promoted" | "stale"; generation: number; sourceDigest: string; promotedDigest?: string }>; +const DIGEST = /^[a-f0-9]{64}$/u; + +export function parseBrokerCredentialJournal(value: unknown): BrokerCredentialJournal { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError("invalid credential journal"); + const input = value as Record; const fields = input.state === "refreshing" ? ["version", "state", "generation", "sourceDigest"] : ["version", "state", "generation", "sourceDigest", "promotedDigest"]; + if (Object.keys(input).length !== fields.length || fields.some((key) => !Object.hasOwn(input, key)) || input.version !== "noopolis.daimon.broker-credential-journal.v1" || !["refreshing", "promoted", "stale"].includes(String(input.state)) || !Number.isSafeInteger(input.generation) || (input.generation as number) < 0 || !DIGEST.test(String(input.sourceDigest)) || (input.state !== "refreshing" && !DIGEST.test(String(input.promotedDigest)))) throw new TypeError("invalid credential journal"); + return input as BrokerCredentialJournal; +} + +export function recoverBrokerCredentialJournal(journal: BrokerCredentialJournal, authorityDigest: string): "ready" | "stale" { + if (journal.state === "stale") return "stale"; + if (journal.state === "refreshing") return authorityDigest === journal.sourceDigest ? "ready" : "stale"; + return authorityDigest === journal.promotedDigest ? "ready" : "stale"; +} diff --git a/src/runtime/engineBrokerMcpFacade.test.ts b/src/runtime/engineBrokerMcpFacade.test.ts new file mode 100644 index 0000000..5244b4f --- /dev/null +++ b/src/runtime/engineBrokerMcpFacade.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import test from "node:test"; +import { startEngineBrokerMcpFacade } from "./engineBrokerMcpFacade.js"; + +test("MCP facade routes only valid active capabilities to the registered mount", async () => { + let calls=0;const target=createServer((_request,response)=>{calls++;response.writeHead(200,{"content-type":"application/json"});response.end('{"ok":true}');});await new Promise((resolve)=>target.listen(0,"127.0.0.1",resolve));const address=target.address();if(address===null||typeof address==="string")throw new Error(); + const facade=await startEngineBrokerMcpFacade();const token=facade.register("agent","turn",`http://127.0.0.1:${address.port}/mcp`);const call=(value:string)=>fetch("http://127.0.0.1:43124/mcp",{method:"POST",headers:{authorization:`Bearer ${value}`,"content-type":"application/json"},body:"{}"}); + try{assert.equal((await call("wrong-token-abcdefghijklmnopqrstuvwxyz0123456789")).status,403);assert.equal((await call(token)).status,200);assert.equal(calls,1);facade.revoke("turn");assert.equal((await call(token)).status,403);assert.equal(calls,1);}finally{await facade.close();await new Promise((resolve)=>target.close(()=>resolve()));} +}); diff --git a/src/runtime/engineBrokerMcpFacade.ts b/src/runtime/engineBrokerMcpFacade.ts new file mode 100644 index 0000000..156506b --- /dev/null +++ b/src/runtime/engineBrokerMcpFacade.ts @@ -0,0 +1,10 @@ +import { createServer } from "node:http"; +import { EngineBrokerCapabilities } from "./engineBrokerCapabilities.js"; + +export async function startEngineBrokerMcpFacade() { + const capabilities=new EngineBrokerCapabilities();const targets=new Map(); + const server=createServer((request,response)=>{void(async()=>{try{if(request.url!=="/mcp"||request.method!=="POST")throw new Error();const match=request.headers.authorization?.match(/^Bearer ([A-Za-z0-9_-]{40,})$/u);if(!match)throw new Error();const scope=capabilities.authorizeToken(match[1]!);if(!scope)throw new Error();const target=targets.get(scope.turnId);if(!target)throw new Error();const body=await bounded(request);const payload=body.buffer.slice(body.byteOffset,body.byteOffset+body.byteLength) as ArrayBuffer;const upstream=await fetch(target,{method:"POST",headers:{"content-type":request.headers["content-type"]??"application/json","accept":request.headers.accept??"application/json, text/event-stream"},body:payload});response.writeHead(upstream.status,{"content-type":upstream.headers.get("content-type")??"application/json","cache-control":"no-store"});response.end(Buffer.from(await upstream.arrayBuffer()));}catch{response.writeHead(403,{"content-type":"application/json","cache-control":"no-store"});response.end('{"error":"forbidden"}');}})();}); + await new Promise((resolve,reject)=>{server.once("error",reject);server.listen(43_124,"127.0.0.1",()=>{server.off("error",reject);resolve();});}); + return {register(agentId:string,turnId:string,endpoint:string){const url=new URL(endpoint);if(url.protocol!=="http:"||url.hostname!=="127.0.0.1"||url.pathname!=="/mcp")throw new TypeError("invalid scoped MCP mount");if(targets.has(turnId))throw new Error("MCP turn already registered");targets.set(turnId,url.href);return capabilities.issue(agentId,turnId,15*60_000,128);},revoke(turnId:string){targets.delete(turnId);capabilities.revoke(turnId);},close:()=>new Promise((resolve,reject)=>server.close((error)=>error?reject(error):resolve()))}; +} +async function bounded(request:AsyncIterable):Promise{const chunks:Buffer[]=[];let bytes=0;for await(const chunk of request){const value=Buffer.from(chunk as Uint8Array);bytes+=value.length;if(bytes>1024*1024)throw new Error();chunks.push(value);}return Buffer.concat(chunks);} diff --git a/src/runtime/engineBrokerService.test.ts b/src/runtime/engineBrokerService.test.ts new file mode 100644 index 0000000..e4fe882 --- /dev/null +++ b/src/runtime/engineBrokerService.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { EngineBrokerControlClient } from "./engineBrokerControlClient.js"; +import { startEngineBrokerServiceWithIdentity, type EngineBrokerServiceEngine } from "./engineBrokerService.js"; + +test("broker backend serves a turn and preserves worker attestation", async () => { + await withService(async (client) => {await client.ready();assert.equal(await client.turn("agent-a","wake-a","hello","http://127.0.0.1:44001/mcp"),"answer");}); +}); + +test("health challenge fails closed when the credential realm is stale",async()=>{ + await withService(async(client)=>assert.rejects(client.ready(),/unavailable/u),undefined,()=>({providerProxyPort:43123,mcpFacadePort:43124,registrations:1,credentialStale:true,realmLease:true,workerIsolation:true})); +}); + +test("client cancellation reaches the active broker worker", async () => { + let aborted=false,markStarted!:()=>void,markAborted!:()=>void;const started=new Promise((resolve)=>{markStarted=resolve;}),wasAborted=new Promise((resolve)=>{markAborted=resolve;}); + await withService(async (client) => { + const controller=new AbortController(); + const pending=client.turn("agent-a","wake-a","hello","http://127.0.0.1:44001/mcp",controller.signal); + await started;controller.abort(); + await assert.rejects(pending,/unavailable/u); + await wasAborted;assert.equal(aborted,true); + }, async (_agent,_wake,_prompt,_endpoint,signal) => new Promise((_resolve,reject)=>{markStarted();signal?.addEventListener("abort",()=>{aborted=true;markAborted();reject(new Error("cancelled"));},{once:true});})); +}); + +test("service shutdown aborts turns and closes connected clients", async () => { + const directory=await mkdtemp(path.join(tmpdir(),"daimon-broker-service-")),socketPath=path.join(directory,"broker.sock"); + let aborted=false,markStarted!:()=>void;const started=new Promise((resolve)=>{markStarted=resolve;});const engine=makeEngine(async (_agent,_wake,_prompt,_endpoint,signal)=>new Promise((_resolve,reject)=>{markStarted();signal?.addEventListener("abort",()=>{aborted=true;reject(new Error("cancelled"));},{once:true});})); + const service=await startEngineBrokerServiceWithIdentity(engine,socketPath,process.getuid!()); + const pending=new EngineBrokerControlClient(socketPath).turn("agent-a","wake-a","hello","http://127.0.0.1:44001/mcp");const rejected=assert.rejects(pending,/unavailable/u); + await started;await service.close();await rejected;assert.equal(aborted,true);await rm(directory,{recursive:true,force:true}); +}); + +async function withService(run:(client:EngineBrokerControlClient)=>Promise,turn:EngineBrokerServiceEngine["turn"]=async()=>({text:"answer",workerPid:22,workerUid:2200,workerStartTime:"123"}),readiness:EngineBrokerServiceEngine["readiness"]=()=>({providerProxyPort:43123,mcpFacadePort:43124,registrations:1,credentialStale:false,realmLease:true,workerIsolation:true})):Promise{ + const directory=await mkdtemp(path.join(tmpdir(),"daimon-broker-service-")),socketPath=path.join(directory,"broker.sock"),engine=makeEngine(turn,readiness);const service=await startEngineBrokerServiceWithIdentity(engine,socketPath,process.getuid!()); + try{await run(new EngineBrokerControlClient(socketPath));}finally{await service.close();await rm(directory,{recursive:true,force:true});} +} +function makeEngine(turn:EngineBrokerServiceEngine["turn"],readiness:EngineBrokerServiceEngine["readiness"]=()=>({providerProxyPort:43123,mcpFacadePort:43124,registrations:1,credentialStale:false,realmLease:true,workerIsolation:true})):EngineBrokerServiceEngine{return {turn,readiness,close:async()=>undefined};} diff --git a/src/runtime/engineBrokerService.ts b/src/runtime/engineBrokerService.ts new file mode 100644 index 0000000..da96103 --- /dev/null +++ b/src/runtime/engineBrokerService.ts @@ -0,0 +1,25 @@ +import { chmod, lstat, unlink } from "node:fs/promises"; +import { createServer, type Server, type Socket } from "node:net"; +import { encodeEngineBrokerFrame,EngineBrokerFrameDecoder,parseEngineBrokerRequest,type EngineBrokerResponse } from "./engineBrokerProtocol.js"; +import { EngineBrokerTurnFailure } from "./grokEngineBroker.js"; +export interface EngineBrokerServiceEngine { + turn(agentId:string,wakeId:string,prompt:string,mcpEndpoint:string,signal?:AbortSignal):Promise>; + readiness():Readonly<{providerProxyPort:number;mcpFacadePort:number;registrations:number;credentialStale:boolean;realmLease:boolean;workerIsolation:boolean}>; + close():Promise; +} + +export function startEngineBrokerService(broker:EngineBrokerServiceEngine,socketPath="/run/daimon-engine-broker/backend.sock"){ + return startEngineBrokerServiceWithIdentity(broker,socketPath,2100); +} +export async function startEngineBrokerServiceWithIdentity(broker:EngineBrokerServiceEngine,socketPath:string,expectedUid:number){ + if(process.getuid?.()!==expectedUid)throw new Error("engine broker service requires broker identity");await removeOwnedSocket(socketPath,expectedUid);const active=new Map(),connections=new Set(); + const server=createServer((socket)=>{connections.add(socket);socket.once("close",()=>connections.delete(socket));handle(socket,broker,active);});await listen(server,socketPath); + try{await chmod(socketPath,0o600);await verifySocket(socketPath,expectedUid);}catch(error){await closeServer(server,connections);await unlink(socketPath).catch(()=>undefined);throw error;} + let closed=false;return {close:async()=>{if(closed)return;closed=true;for(const controller of active.values())controller.abort();const results=await Promise.allSettled([closeServer(server,connections),broker.close()]);await unlink(socketPath).catch(()=>undefined);const failures=results.flatMap((result)=>result.status==="rejected"?[result.reason]:[]);if(failures.length)throw new AggregateError(failures,"engine broker service shutdown failed");},health:()=>({activeTurns:active.size,socketPath})}; +} +function handle(socket:Socket,broker:EngineBrokerServiceEngine,active:Map):void{const decoder=new EngineBrokerFrameDecoder();let started=false,owned:Readonly<{turnId:string;controller:AbortController}>|undefined;socket.once("close",()=>owned?.controller.abort());socket.on("data",(chunk)=>{try{for(const value of decoder.push(chunk)){const request=parseEngineBrokerRequest(value);if(request.kind==="health"){if(started)throw new Error();started=true;const ready=broker.readiness();if(ready.providerProxyPort!==43123||ready.mcpFacadePort!==43124||ready.registrations<1||ready.credentialStale||!ready.realmLease||!ready.workerIsolation)throw new Error();return send(socket,{version:request.version,kind:"ready",requestId:request.requestId,brokerUid:2100,providerProxyPort:43123,mcpFacadePort:43124,registrations:ready.registrations,credentialStale:false,realmLease:true,workerIsolation:true});}if(request.kind==="cancel_turn"){active.get(request.turnId)?.abort();continue;}if(started||active.has(request.turnId))throw new Error();started=true;const controller=new AbortController();owned={turnId:request.turnId,controller};active.set(request.turnId,controller);socket.write(encodeEngineBrokerFrame({version:request.version,kind:"accepted",requestId:request.requestId,turnId:request.turnId}));void broker.turn(request.agentId,request.wakeId,request.prompt,request.mcpEndpoint,controller.signal).then((result)=>send(socket,{version:request.version,kind:"completed",requestId:request.requestId,turnId:request.turnId,...result}),(error:unknown)=>send(socket,{version:request.version,kind:"failed",requestId:request.requestId,turnId:request.turnId,code:error instanceof EngineBrokerTurnFailure?error.code:controller.signal.aborted?"cancelled":"engine_failed",...(error instanceof EngineBrokerTurnFailure&&error.diagnostic?{diagnostic:error.diagnostic}:{})})).finally(()=>{if(active.get(request.turnId)===controller)active.delete(request.turnId);owned=undefined;});}}catch{socket.destroy();}});} +function send(socket:Socket,response:EngineBrokerResponse):void{if(!socket.destroyed)socket.end(encodeEngineBrokerFrame(response));} +async function removeOwnedSocket(file:string,uid:number):Promise{try{const entry=await lstat(file);if(!entry.isSocket()||Number(entry.uid)!==uid)throw new Error("unsafe broker socket");await unlink(file);}catch(error){if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;}} +async function verifySocket(file:string,uid:number):Promise{const entry=await lstat(file);if(!entry.isSocket()||Number(entry.uid)!==uid||(Number(entry.mode)&0o777)!==0o600)throw new Error("unsafe broker socket");} +function listen(server:Server,file:string):Promise{return new Promise((resolve,reject)=>{server.once("error",reject);server.listen(file,()=>{server.off("error",reject);resolve();});});} +function closeServer(server:Server,connections:Set):Promise{for(const socket of connections)socket.destroy();return new Promise((resolve,reject)=>server.close((error)=>error?reject(error):resolve()));} diff --git a/src/runtime/engineBrokerServiceCli.test.ts b/src/runtime/engineBrokerServiceCli.test.ts new file mode 100644 index 0000000..7e5ff97 --- /dev/null +++ b/src/runtime/engineBrokerServiceCli.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseEngineBrokerServiceConfig } from "./engineBrokerServiceCli.js"; + +test("parses the closed broker service configuration",()=>{ + const registration=reg("agent-a",0);assert.deepEqual(parseEngineBrokerServiceConfig({version:"noopolis.daimon.engine-broker-service.v1",credentialHome:"/var/lib/daimon-engine-broker/credential",turnStore:"/var/lib/daimon-engine-broker/turns",registrations:[registration]}),{credentialHome:"/var/lib/daimon-engine-broker/credential",turnStore:"/var/lib/daimon-engine-broker/turns",registrations:[registration]}); +}); + +test("rejects caller-selected commands, duplicate identities, and traversal",()=>{ + const base={version:"noopolis.daimon.engine-broker-service.v1",credentialHome:"/var/lib/daimon-engine-broker/credential",turnStore:"/var/lib/daimon-engine-broker/turns",registrations:[reg("agent-a",0)]}; + assert.throws(()=>parseEngineBrokerServiceConfig({...base,grokCommand:"evil"})); + assert.throws(()=>parseEngineBrokerServiceConfig({...base,turnStore:"/var/lib/../secret"})); + assert.throws(()=>parseEngineBrokerServiceConfig({...base,registrations:[reg("agent-a",0),reg("agent-a",1)]})); +}); +const reg=(agentId:string,slot:number)=>({agentId,slot,workerUid:2200+slot,workspace:`/workspace/${slot}`,profilePath:`/workers/${slot}/.grok/sandbox.toml`,eventsPath:`/workers/${slot}/.grok/sandbox-events.jsonl`,profileSha256:"a".repeat(64)}); diff --git a/src/runtime/engineBrokerServiceCli.ts b/src/runtime/engineBrokerServiceCli.ts new file mode 100644 index 0000000..bbc89cd --- /dev/null +++ b/src/runtime/engineBrokerServiceCli.ts @@ -0,0 +1,26 @@ +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import { startEngineBrokerService } from "./engineBrokerService.js"; +import { startGrokEngineBroker, type GrokEngineBrokerRegistration } from "./grokEngineBroker.js"; + +export const ENGINE_BROKER_SERVICE_CONFIG = "/etc/daimon-engine-broker/service.json"; +const MAX_CONFIG_BYTES=65_536; + +export async function runEngineBrokerServiceCli():Promise{ + if(process.getuid?.()!==2100)throw new Error("engine broker service requires broker identity"); + const config=parseEngineBrokerServiceConfig(await readRootConfig(ENGINE_BROKER_SERVICE_CONFIG)); + const broker=await startGrokEngineBroker({grokCommand:"/usr/local/bin/grok",nativeClient:"/opt/daimon/bin/daimon-engine-broker",credentialHome:config.credentialHome,turnStore:config.turnStore,registrations:config.registrations}); + const service=await startEngineBrokerService(broker);let stopping:Promise|undefined; + const stop=()=>{stopping??=service.close();return stopping;}; + const onSignal=()=>{void stop().catch(()=>{process.exitCode=1;});};process.once("SIGINT",onSignal);process.once("SIGTERM",onSignal); +} + +export function parseEngineBrokerServiceConfig(value:unknown):Readonly<{credentialHome:string;turnStore:string;registrations:readonly GrokEngineBrokerRegistration[]}>{ + if(value===null||typeof value!=="object"||Array.isArray(value))throw new TypeError("invalid engine broker service config");const input=value as Record; + if(Object.keys(input).length!==4||input.version!=="noopolis.daimon.engine-broker-service.v1"||typeof input.credentialHome!=="string"||typeof input.turnStore!=="string"||!Array.isArray(input.registrations))throw new TypeError("invalid engine broker service config"); + const absolute=(item:string)=>item.startsWith("/")&&!item.includes("/../")&&!item.endsWith("/..");if(!absolute(input.credentialHome)||!absolute(input.turnStore))throw new TypeError("invalid engine broker service config"); + const seen=new Set();const registrations=input.registrations.map((entry)=>{if(entry===null||typeof entry!=="object"||Array.isArray(entry))throw new TypeError("invalid engine broker service config");const item=entry as Record;if(Object.keys(item).length!==7||typeof item.agentId!=="string"||!item.agentId.trim()||!Number.isSafeInteger(item.slot)||(item.slot as number)<0||!Number.isSafeInteger(item.workerUid)||(item.workerUid as number)<2200||typeof item.workspace!=="string"||!absolute(item.workspace)||typeof item.profilePath!=="string"||!absolute(item.profilePath)||typeof item.eventsPath!=="string"||!absolute(item.eventsPath)||typeof item.profileSha256!=="string"||!/^[a-f0-9]{64}$/u.test(item.profileSha256)||item.profilePath!==`${item.eventsPath.replace(/\/sandbox-events\.jsonl$/u,"")}/sandbox.toml`||seen.has(item.agentId))throw new TypeError("invalid engine broker service config");seen.add(item.agentId);return {agentId:item.agentId,slot:item.slot as number,workerUid:item.workerUid as number,workspace:item.workspace,profilePath:item.profilePath,eventsPath:item.eventsPath,profileSha256:item.profileSha256};}); + if(registrations.length===0)throw new TypeError("invalid engine broker service config");return {credentialHome:input.credentialHome,turnStore:input.turnStore,registrations}; +} + +async function readRootConfig(file:string):Promise{const handle=await open(file,constants.O_RDONLY|constants.O_NOFOLLOW);try{const stat=await handle.stat();if(!stat.isFile()||stat.uid!==0||stat.gid!==2100||(stat.mode&0o777)!==0o440||stat.size<2||stat.size>MAX_CONFIG_BYTES)throw new Error("unsafe engine broker service config");const bytes=await handle.readFile();if(bytes.length>MAX_CONFIG_BYTES)throw new Error("unsafe engine broker service config");return JSON.parse(bytes.toString("utf8"));}finally{await handle.close();}} diff --git a/src/runtime/engineBrokerSingleflight.test.ts b/src/runtime/engineBrokerSingleflight.test.ts new file mode 100644 index 0000000..f5ece59 --- /dev/null +++ b/src/runtime/engineBrokerSingleflight.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { EngineBrokerGenerationFence, EngineBrokerSingleflight } from "./engineBrokerSingleflight.js"; + +test("credential refresh is singleflight and generation fenced", async () => { + const flight = new EngineBrokerSingleflight(); let calls = 0; let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const operation = async () => { calls += 1; await gate; return 7; }; + const left = flight.run(operation); const right = flight.run(operation); release(); + assert.equal(await left, 7); assert.equal(await right, 7); assert.equal(calls, 1); + const fence = new EngineBrokerGenerationFence(); const zero = fence.snapshot(); assert.equal(fence.promote(zero), 1); + assert.throws(() => fence.promote(zero), /conflict/); fence.markStale(); assert.throws(() => fence.snapshot(), /stale/); +}); diff --git a/src/runtime/engineBrokerSingleflight.ts b/src/runtime/engineBrokerSingleflight.ts new file mode 100644 index 0000000..36683da --- /dev/null +++ b/src/runtime/engineBrokerSingleflight.ts @@ -0,0 +1,18 @@ +export class EngineBrokerSingleflight { + private active: Promise | undefined; + run(operation: () => Promise): Promise { + if (this.active !== undefined) return this.active; + const current = operation(); this.active = current; + void current.then(() => { if (this.active === current) this.active = undefined; }, () => { if (this.active === current) this.active = undefined; }); + return current; + } +} + +export class EngineBrokerGenerationFence { + private generation = 0; + private stale = false; + restore(generation:number):void{if(this.stale||this.generation!==0||!Number.isSafeInteger(generation)||generation<0)throw new Error("broker credential generation conflict");this.generation=generation;} + snapshot(): number { if (this.stale) throw new Error("broker credential authority is stale"); return this.generation; } + promote(expected: number): number { if (this.stale || expected !== this.generation) throw new Error("broker credential generation conflict"); return ++this.generation; } + markStale(): void { this.stale = true; } +} diff --git a/src/runtime/engineBrokerTurnRegistry.test.ts b/src/runtime/engineBrokerTurnRegistry.test.ts new file mode 100644 index 0000000..16172c7 --- /dev/null +++ b/src/runtime/engineBrokerTurnRegistry.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { EngineBrokerTurnRegistry } from "./engineBrokerTurnRegistry.js"; + +const start = (prompt = "work") => ({ version: "noopolis.daimon.engine-broker.v1", kind: "start_turn", requestId: "request-1", turnId: "turn-1", agentId: "agent-1", wakeId: "wake-1", prompt,mcpEndpoint:"http://127.0.0.1:4567/mcp" } as const); +test("turn registry replays terminal results across restart and rejects conflicts", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-broker-turns-")); + try { + const first = new EngineBrokerTurnRegistry(root,"boot-a"); assert.equal(await first.begin(start()), "start"); + await assert.rejects(first.begin(start()), /already active/); + const response = { version: "noopolis.daimon.engine-broker.v1", kind: "completed", requestId: "request-1", turnId: "turn-1", text: "done", workerPid: 11, workerUid: 2200, workerStartTime: "123" } as const; + await first.finish(start(), response); + assert.deepEqual(await new EngineBrokerTurnRegistry(root,"boot-b").begin(start()), { replay: response }); + await assert.rejects(first.begin(start("different")), /conflict/); + } finally { await rm(root, { recursive: true, force: true }); } +}); +test("turn registry fails an orphaned active turn once after broker restart",async()=>{const root=await mkdtemp(path.join(os.tmpdir(),"daimon-broker-turns-"));try{assert.equal(await new EngineBrokerTurnRegistry(root,"boot-a").begin(start()),"start");const replay=await new EngineBrokerTurnRegistry(root,"boot-b").begin(start());assert.equal(typeof replay,"object");if(typeof replay==="object")assert.equal(replay.replay.kind,"failed");assert.deepEqual(await new EngineBrokerTurnRegistry(root,"boot-c").begin(start()),replay);}finally{await rm(root,{recursive:true,force:true});}}); +test("turn registry durably replays a sanitized pre-attestation failure",async()=>{const root=await mkdtemp(path.join(os.tmpdir(),"daimon-broker-turns-"));try{const registry=new EngineBrokerTurnRegistry(root,"boot-a");assert.equal(await registry.begin(start()),"start");const response={version:"noopolis.daimon.engine-broker.v1",kind:"failed",requestId:"request-1",turnId:"turn-1",code:"engine_failed",diagnostic:{status:"worker_failed",stage:"attestation",failureClass:"profile_missing",profileApplied:false,exitCode:0,termSignal:0,workerPid:42,workerUid:2200,startTicks:"123"}} as const;await registry.finish(start(),response);assert.deepEqual(await new EngineBrokerTurnRegistry(root,"boot-b").begin(start()),{replay:response});}finally{await rm(root,{recursive:true,force:true});}}); +test("turn registry rejects a persisted diagnostic with undeclared secret-bearing fields",async()=>{const root=await mkdtemp(path.join(os.tmpdir(),"daimon-broker-turns-"));try{const registry=new EngineBrokerTurnRegistry(root,"boot-a");assert.equal(await registry.begin(start()),"start");const [name]=await readdir(root);const file=path.join(root,name!);const record=JSON.parse(await readFile(file,"utf8")) as Record;record.state="terminal";record.response={version:"noopolis.daimon.engine-broker.v1",kind:"failed",requestId:"request-1",turnId:"turn-1",code:"engine_failed",rawOutput:"secret"};await writeFile(file,JSON.stringify(record));await assert.rejects(new EngineBrokerTurnRegistry(root,"boot-b").begin(start()),/invalid broker frame/u);}finally{await rm(root,{recursive:true,force:true});}}); diff --git a/src/runtime/engineBrokerTurnRegistry.ts b/src/runtime/engineBrokerTurnRegistry.ts new file mode 100644 index 0000000..bdee3c3 --- /dev/null +++ b/src/runtime/engineBrokerTurnRegistry.ts @@ -0,0 +1,34 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { mkdir, open, readFile, rename, unlink } from "node:fs/promises"; +import path from "node:path"; +import { parseEngineBrokerResponse,type EngineBrokerRequest, type EngineBrokerResponse } from "./engineBrokerProtocol.js"; + +type Start = Extract; +type Terminal = Extract; +type Record = { version: "noopolis.daimon.engine-broker-turn.v1"; digest: string; state: "active" | "terminal"; bootId: string; response?: Terminal }; +const digest = (request: Start): string => createHash("sha256").update(JSON.stringify([request.turnId, request.agentId, request.wakeId, request.prompt,request.mcpEndpoint])).digest("hex"); +const safe = (turnId: string): string => `${createHash("sha256").update(turnId).digest("hex")}.json`; + +export class EngineBrokerTurnRegistry { + constructor(private readonly root: string,private readonly bootId:string=randomUUID()) {} + async begin(request: Start): Promise<"start" | { replay: Terminal }> { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + const file = path.join(this.root, safe(request.turnId)); const expected = digest(request); + try { const handle = await open(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); try { await handle.writeFile(`${JSON.stringify({ version: "noopolis.daimon.engine-broker-turn.v1", digest: expected, state: "active",bootId:this.bootId })}\n`); await handle.sync(); } finally { await handle.close(); } await syncDirectory(this.root); return "start"; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw new Error("broker turn registry unavailable"); } + const observed = JSON.parse(await readFile(file, "utf8")) as Record; + if (observed.version !== "noopolis.daimon.engine-broker-turn.v1" || observed.digest !== expected) throw new Error("broker turn conflict"); + if (observed.state === "terminal" && observed.response !== undefined) { const response=parseEngineBrokerResponse(observed.response);if(response.kind!=="completed"&&response.kind!=="failed")throw new Error("broker turn registry unavailable");return { replay: response }; } + if(observed.state==="active"&&observed.bootId!==this.bootId){const response={version:request.version,kind:"failed",requestId:request.requestId,turnId:request.turnId,code:"engine_failed"} as const;await this.finish(request,response);return {replay:response};} + throw new Error("broker turn already active"); + } + async finish(request: Start, response: Terminal): Promise { + const file = path.join(this.root, safe(request.turnId)); const temporary = `${file}.${randomUUID()}.tmp`; + const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + try { await handle.writeFile(`${JSON.stringify({ version: "noopolis.daimon.engine-broker-turn.v1", digest: digest(request), state: "terminal",bootId:this.bootId, response })}\n`); await handle.sync(); } finally { await handle.close(); } + try { await rename(temporary, file); await syncDirectory(this.root); } finally { await unlink(temporary).catch(() => undefined); } + } +} + +async function syncDirectory(directory: string): Promise { const handle = await open(directory, constants.O_RDONLY); try { await handle.sync(); } finally { await handle.close(); } } diff --git a/src/runtime/grokBrokerCredentialAuthority.test.ts b/src/runtime/grokBrokerCredentialAuthority.test.ts new file mode 100644 index 0000000..98c264a --- /dev/null +++ b/src/runtime/grokBrokerCredentialAuthority.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmod,mkdir,mkdtemp,readFile,rm,writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { DurableGrokBrokerCredentialAuthority } from "./grokBrokerCredentialAuthority.js"; + +test("credential authority resumes only a journal matching durable credential",async()=>{const home=await fixture();try{const bytes=await import("node:fs/promises").then(({readFile})=>readFile(path.join(home,"auth.json"))),digest=createHash("sha256").update(bytes).digest("hex");await journal(home,{state:"promoted",generation:1,sourceDigest:"a".repeat(64),promotedDigest:digest});const authority=new DurableGrokBrokerCredentialAuthority("/usr/local/bin/grok",home);await authority.initialize();assert.equal((await authority.accessToken(false)).length>=32,true);}finally{await rm(home,{recursive:true,force:true});}}); + +test("credential authority fails closed after indeterminate restart",async()=>{const home=await fixture();try{await journal(home,{state:"refreshing",generation:1,sourceDigest:"b".repeat(64)});const authority=new DurableGrokBrokerCredentialAuthority("/usr/local/bin/grok",home);await assert.rejects(authority.initialize(),/unavailable/u);await assert.rejects(authority.accessToken(false),/unavailable/u);}finally{await rm(home,{recursive:true,force:true});}}); + +test("stale fence durably records the rejected credential digest and generation for operator recovery",async()=>{const home=await fixture();try{const auth=await readFile(path.join(home,"auth.json")),digest=createHash("sha256").update(auth).digest("hex");await journal(home,{state:"promoted",generation:7,sourceDigest:"a".repeat(64),promotedDigest:digest});const authority=new DurableGrokBrokerCredentialAuthority("/usr/local/bin/grok",home);await authority.initialize();await assert.rejects(authority.markRejected(),/unavailable/u);const observed=JSON.parse(await readFile(path.join(home,".daimon-broker","credential-journal.json"),"utf8")) as Record;assert.equal(observed.state,"stale");assert.equal(observed.generation,7);assert.equal(observed.sourceDigest,digest);assert.equal(observed.promotedDigest,digest);}finally{await rm(home,{recursive:true,force:true});}}); + +test("a late rejection cannot stale-fence a newer access token",async()=>{const home=await fixture();try{const authority=new DurableGrokBrokerCredentialAuthority("/usr/local/bin/grok",home);await authority.initialize();const old=createHash("sha256").update("k".repeat(40)).digest("hex");await writeFile(path.join(home,"auth.json"),JSON.stringify({subscription:{key:"n".repeat(40),refresh_token:"r".repeat(20)}}),{mode:0o600});await assert.rejects(authority.markRejected(old),/unavailable/u);assert.equal(authority.isStale(),false);}finally{await rm(home,{recursive:true,force:true});}}); + +async function fixture():Promise{const home=await mkdtemp(path.join(tmpdir(),"grok-authority-"));const value={subscription:{key:"k".repeat(40),refresh_token:"r".repeat(20)}};await writeFile(path.join(home,"auth.json"),JSON.stringify(value),{mode:0o600});return home;} +async function journal(home:string,value:Record):Promise{const root=path.join(home,".daimon-broker");await mkdir(root,{mode:0o700});const file=path.join(root,"credential-journal.json");await writeFile(file,JSON.stringify({version:"noopolis.daimon.broker-credential-journal.v1",...value}),{mode:0o600});await chmod(file,0o600);} diff --git a/src/runtime/grokBrokerCredentialAuthority.ts b/src/runtime/grokBrokerCredentialAuthority.ts new file mode 100644 index 0000000..030cd07 --- /dev/null +++ b/src/runtime/grokBrokerCredentialAuthority.ts @@ -0,0 +1,36 @@ +import { randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { mkdir, open, rename, unlink } from "node:fs/promises"; +import path from "node:path"; +import { readGrokBrokerCredential } from "./grokBrokerCredentialReader.js"; +import { refreshGrokBrokerCredential } from "./grokBrokerRefresh.js"; +import { EngineBrokerGenerationFence, EngineBrokerSingleflight } from "./engineBrokerSingleflight.js"; +import { parseBrokerCredentialJournal, recoverBrokerCredentialJournal } from "./engineBrokerCredentialJournal.js"; + +export class DurableGrokBrokerCredentialAuthority { + private readonly flight = new EngineBrokerSingleflight(); private readonly fence = new EngineBrokerGenerationFence(); private stale = false;private initialized=false;private staleWrite:Promise|undefined;private lastDigest:string|undefined;private generation=0; + constructor(private readonly command: string, private readonly home: string, private readonly authFile = path.join(home, "auth.json")) {} + async initialize():Promise{if(this.initialized)return;const credential=await readGrokBrokerCredential(this.authFile);this.lastDigest=credential.digest;const file=path.join(this.home,".daimon-broker","credential-journal.json");let handle:Awaited>|undefined;try{handle=await open(file,constants.O_RDONLY|constants.O_NOFOLLOW);const stat=await handle.stat();if(!stat.isFile()||stat.uid!==process.getuid?.()||(stat.mode&0o777)!==0o600||stat.size>4096)throw unavailable();const journal=parseBrokerCredentialJournal(JSON.parse(await handle.readFile("utf8")));if(recoverBrokerCredentialJournal(journal,credential.digest)==="stale"){this.generation=journal.generation;await this.markStale();throw unavailable();}this.generation=journal.generation;this.fence.restore(journal.generation);}catch(error){if((error as NodeJS.ErrnoException).code!=="ENOENT")throw unavailable();}finally{await handle?.close().catch(()=>undefined);}this.initialized=true;} + async accessToken(forceRefresh: boolean): Promise { + if(!this.initialized)await this.initialize();if (this.stale) throw unavailable(); if (!forceRefresh) return (await readGrokBrokerCredential(this.authFile)).accessToken; + return this.refresh(); + } + async refreshAfterRejection(rejectedTokenDigest:string):Promise{if(!/^[a-f0-9]{64}$/u.test(rejectedTokenDigest))throw unavailable();return this.refresh(rejectedTokenDigest);} + private refresh(rejectedTokenDigest?:string):Promise{return this.flight.run(async () => { + if (this.stale) throw unavailable(); const generation = this.fence.snapshot(); const before = await readGrokBrokerCredential(this.authFile);if(rejectedTokenDigest!==undefined&&createHash("sha256").update(before.accessToken).digest("hex")!==rejectedTokenDigest)return before.accessToken; + try { + await this.write({ state: "refreshing", generation, sourceDigest: before.digest }); await refreshGrokBrokerCredential(this.command, this.home); const after = await readGrokBrokerCredential(this.authFile); + if (after.digest === before.digest) throw unavailable(); const next = this.fence.promote(generation);this.generation=next; await this.write({ state: "promoted", generation: next, sourceDigest: before.digest, promotedDigest: after.digest });this.lastDigest=after.digest; return after.accessToken; + } catch { await this.markStale(); throw unavailable(); } + });} + async markRejected(rejectedTokenDigest?:string): Promise { if(rejectedTokenDigest!==undefined){if(!/^[a-f0-9]{64}$/u.test(rejectedTokenDigest))throw unavailable();const current=await readGrokBrokerCredential(this.authFile);if(createHash("sha256").update(current.accessToken).digest("hex")!==rejectedTokenDigest)throw unavailable();}if (!this.stale) await this.markStale();throw unavailable(); } + isStale():boolean{return this.stale;} + private markStale():Promise{if(this.staleWrite)return this.staleWrite;this.stale=true;this.fence.markStale();return this.staleWrite=(async()=>{const rejected=this.lastDigest??(await readGrokBrokerCredential(this.authFile)).digest;await this.write({state:"stale",generation:this.generation,sourceDigest:rejected,promotedDigest:rejected});})();} + private async write(value: { state: "refreshing" | "promoted" | "stale"; generation: number; sourceDigest: string; promotedDigest?: string }): Promise { + const root = path.join(this.home, ".daimon-broker"); await mkdir(root, { recursive: true, mode: 0o700 }); const file = path.join(root, "credential-journal.json"); const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; + const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); try { await handle.writeFile(`${JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", ...value })}\n`); await handle.sync(); } finally { await handle.close(); } + try { await rename(temporary, file); const directory = await open(root, constants.O_RDONLY); try { await directory.sync(); } finally { await directory.close(); } } finally { await unlink(temporary).catch(() => undefined); } + } +} +const unavailable = (): Error => new Error("Grok broker credential authority unavailable"); diff --git a/src/runtime/grokBrokerCredentialReader.test.ts b/src/runtime/grokBrokerCredentialReader.test.ts new file mode 100644 index 0000000..b59d613 --- /dev/null +++ b/src/runtime/grokBrokerCredentialReader.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { readGrokBrokerCredential } from "./grokBrokerCredentialReader.js"; +test("broker reads only a private refreshable credential without exposing refresh", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-broker-auth-")); const file = path.join(root, "auth.json"); const token = "a".repeat(64); await writeFile(file, JSON.stringify({ realm: { key: token, refresh_token: "r".repeat(32) } }), { mode: 0o600 }); + try { const result = await readGrokBrokerCredential(file); assert.equal(result.accessToken, token); assert.match(result.digest, /^[a-f0-9]{64}$/u); assert.equal("refreshToken" in result, false); await chmod(file, 0o644); await assert.rejects(readGrokBrokerCredential(file), /unavailable/); await symlink(file, `${file}.link`); await assert.rejects(readGrokBrokerCredential(`${file}.link`), /unavailable/); } + finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/runtime/grokBrokerCredentialReader.ts b/src/runtime/grokBrokerCredentialReader.ts new file mode 100644 index 0000000..facb1ea --- /dev/null +++ b/src/runtime/grokBrokerCredentialReader.ts @@ -0,0 +1,17 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, open } from "node:fs/promises"; + +export async function readGrokBrokerCredential(file: string): Promise> { + let handle: Awaited> | undefined; let bytes: Buffer | undefined; + try { + const before = await lstat(file); assertFile(before); handle = await open(file, constants.O_RDONLY | noFollow()); const opened = await handle.stat(); assertFile(opened); + if (before.dev !== opened.dev || before.ino !== opened.ino) throw new Error(); bytes = await handle.readFile(); if (bytes.length > 64 * 1024) throw new Error(); const after = await handle.stat(); if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error(); + const root = JSON.parse(bytes.toString("utf8")) as Record; const rows = Object.values(root).filter((value): value is Record => value !== null && typeof value === "object" && !Array.isArray(value)); + if (rows.length !== 1 || typeof rows[0]!.key !== "string" || rows[0]!.key.length < 32 || typeof rows[0]!.refresh_token !== "string" || rows[0]!.refresh_token.length < 16) throw new Error(); + return { accessToken: rows[0]!.key, digest: createHash("sha256").update(bytes).digest("hex") }; + } catch { throw new Error("Grok broker credential authority unavailable"); } + finally { bytes?.fill(0); await handle?.close().catch(() => undefined); } +} +function assertFile(entry: Awaited>): void { if (!entry.isFile() || entry.isSymbolicLink() || Number(entry.uid) !== process.getuid?.() || Number(entry.nlink) !== 1 || (Number(entry.mode) & 0o777) !== 0o600 || Number(entry.size) < 2 || Number(entry.size) > 64 * 1024) throw new Error(); } +function noFollow(): number { return constants.O_NOFOLLOW ?? 0; } diff --git a/src/runtime/grokBrokerProxy.test.ts b/src/runtime/grokBrokerProxy.test.ts new file mode 100644 index 0000000..2804daa --- /dev/null +++ b/src/runtime/grokBrokerProxy.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { startGrokBrokerProxy } from "./grokBrokerProxy.js"; + +test("proxy retries one 401 with refreshed broker bearer and shuts down", async () => { + const calls: string[] = []; let refreshes = 0; + const proxy = await startGrokBrokerProxy({ accessToken: async (force) => force ? "second" : "first", markRejected: async () => { refreshes += 1; } }, async (request) => { + calls.push(request.headers.authorization); return calls.length === 1 ? { status: 401, headers: { "content-type": "application/json" }, body: new Uint8Array() } : { status: 200, headers: { "content-type": "text/event-stream" }, body: Buffer.from("data: done\n\n") }; + }); + const token = proxy.capabilities.issue("agent", "turn"); + proxy.registerIsolationGuard("turn", async () => undefined); + const result = await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ stream: true, messages: [] }) }); + assert.equal(result.status, 200); assert.equal(await result.text(), "data: done\n\n"); assert.deepEqual(calls, ["Bearer first", "Bearer second"]); assert.equal(refreshes, 0); + await proxy.close(); await assert.rejects(fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`)); +}); + +test("proxy stale-fences a refreshed credential rejected by upstream",async()=>{let rejected=0;const proxy=await startGrokBrokerProxy({accessToken:async(force)=>force?"second":"first",markRejected:async()=>{rejected++;throw new Error("stale");}},async()=>({status:401,headers:{"content-type":"application/json"},body:new Uint8Array()}));const token=proxy.capabilities.issue("agent","turn");proxy.registerIsolationGuard("turn",async()=>undefined);const response=await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`,{method:"POST",headers:{authorization:`Bearer ${token}`},body:JSON.stringify({stream:true,messages:[]})});assert.equal(response.status,503);assert.equal(rejected,1);await proxy.close();}); + +test("proxy failures expose only a fixed diagnostic", async () => { + const proxy = await startGrokBrokerProxy({ accessToken: async () => { throw new Error("secret-token"); }, markRejected: async () => undefined }, async () => { throw new Error("unreachable"); }); + const token = proxy.capabilities.issue("agent", "turn"); + proxy.registerIsolationGuard("turn", async () => undefined); + const result = await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`, { method: "POST", headers: { authorization: `Bearer ${token}` }, body: JSON.stringify({ stream: true, messages: [] }) }); + assert.equal(result.status, 503); const body = await result.text(); assert.equal(body, '{"error":"broker unavailable"}'); assert.doesNotMatch(body, /secret/u); await proxy.close(); +}); + +test("one turn capability supports multiple guarded cognition requests",async()=>{let guarded=0,calls=0;const proxy=await startGrokBrokerProxy({accessToken:async()=>"provider-token",markRejected:async()=>undefined},async()=>{calls++;return{status:200,headers:{"content-type":"application/json"},body:Buffer.from("{}")};});try{const token=proxy.capabilities.issue("agent","turn");proxy.registerIsolationGuard("turn",async()=>{guarded++;});for(let index=0;index<2;index++){const response=await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`,{method:"POST",headers:{authorization:`Bearer ${token}`},body:JSON.stringify({stream:true,messages:[]})});assert.equal(response.status,200);}assert.equal(calls,2);assert.equal(guarded,2);}finally{await proxy.close();}}); diff --git a/src/runtime/grokBrokerProxy.ts b/src/runtime/grokBrokerProxy.ts new file mode 100644 index 0000000..4dee112 --- /dev/null +++ b/src/runtime/grokBrokerProxy.ts @@ -0,0 +1,29 @@ +import { createHash } from "node:crypto"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { EngineBrokerCapabilities } from "./engineBrokerCapabilities.js"; +import { authorizeGrokBrokerProxyRequest } from "./grokBrokerProxyRequest.js"; + +export type GrokBrokerCredentialAuthority = Readonly<{ accessToken(forceRefresh: boolean): Promise; refreshAfterRejection?(rejectedTokenDigest:string):Promise; markRejected(rejectedTokenDigest?:string): Promise }>; +export type GrokBrokerUpstream = (request: ReturnType) => Promise>; body: Uint8Array }>>; + +export async function startGrokBrokerProxy(authority: GrokBrokerCredentialAuthority, upstream: GrokBrokerUpstream = defaultUpstream): PromisePromise):void; revokeIsolationGuard(turnId:string):void; close(): Promise }>> { + const capabilities = new EngineBrokerCapabilities(); + const guards=new MapPromise>();const server = createServer((request, response) => { void serve(request, response, authority, upstream, capabilities,guards); }); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(43_123, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); + const address = server.address() as AddressInfo; + return { port: address.port, capabilities,registerIsolationGuard(turnId,guard){guards.set(turnId,guard);},revokeIsolationGuard(turnId){guards.delete(turnId);}, close: () => new Promise((resolve, reject) => server.close((error) => error === undefined ? resolve() : reject(error))) }; +} + +async function serve(request: IncomingMessage, response: ServerResponse, authority: GrokBrokerCredentialAuthority, upstream: GrokBrokerUpstream, capabilities: EngineBrokerCapabilities,guards:MapPromise>): Promise { + try { + const body = await readBody(request); const headers = Object.fromEntries(Object.entries(request.headers).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])); + const match=headers.authorization?.match(/^Bearer ([A-Za-z0-9_-]{40,})$/u),scope=match?capabilities.inspectToken(match[1]!):undefined;if(!scope)throw new Error();const guard=guards.get(scope.turnId);if(!guard)throw new Error();await guard(); + let token = await authority.accessToken(false);const rejectedDigest=createHash("sha256").update(token).digest("hex"); let prepared = authorizeGrokBrokerProxyRequest({ method: request.method ?? "", pathname: new URL(request.url ?? "/", "http://127.0.0.1").pathname, headers, body }, capabilities, token); token = ""; + let result = await upstream(prepared); + if (result.status === 401) { token = authority.refreshAfterRejection?await authority.refreshAfterRejection(rejectedDigest):await authority.accessToken(true);const refreshedDigest=createHash("sha256").update(token).digest("hex"); prepared = { ...prepared, headers: { ...prepared.headers, authorization: `Bearer ${token}` } }; token = ""; result = await upstream(prepared);if(result.status===401)await authority.markRejected(refreshedDigest); } + response.writeHead(result.status, { "content-type": result.headers["content-type"] ?? "application/json", "cache-control": "no-store" }); response.end(result.body); + } catch { response.writeHead(503, { "content-type": "application/json", "cache-control": "no-store" }); response.end('{"error":"broker unavailable"}'); } +} +async function readBody(request: IncomingMessage): Promise { const chunks: Buffer[] = []; let bytes = 0; for await (const chunk of request) { const value = Buffer.from(chunk); bytes += value.length; if (bytes > 2 * 1024 * 1024) throw new Error("too large"); chunks.push(value); } return Buffer.concat(chunks); } +const defaultUpstream: GrokBrokerUpstream = async (request) => { const result = await fetch(request.url, { method: "POST", headers: request.headers, body: Buffer.from(request.body) }); return { status: result.status, headers: { "content-type": result.headers.get("content-type") ?? "application/json" }, body: new Uint8Array(await result.arrayBuffer()) }; }; diff --git a/src/runtime/grokBrokerProxyRequest.test.ts b/src/runtime/grokBrokerProxyRequest.test.ts new file mode 100644 index 0000000..73bf578 --- /dev/null +++ b/src/runtime/grokBrokerProxyRequest.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { EngineBrokerCapabilities } from "./engineBrokerCapabilities.js"; +import { authorizeGrokBrokerProxyRequest } from "./grokBrokerProxyRequest.js"; + +test("proxy substitutes broker bearer and rejects arbitrary routes and headers", () => { + const caps = new EngineBrokerCapabilities(); const opaque = caps.issue("a", "t"); const body = Buffer.from(JSON.stringify({ stream: true, messages: [] })); + const request = authorizeGrokBrokerProxyRequest({ method: "POST", pathname: "/v1/chat/completions", headers: { authorization: `Bearer ${opaque}`, cookie: "forbidden" }, body, agentId: "a", turnId: "t" }, caps, "real-bearer"); + assert.equal(request.url, "https://cli-chat-proxy.grok.com/v1/chat/completions"); assert.equal(request.headers.authorization, "Bearer real-bearer"); assert.equal("cookie" in request.headers, false); + assert.throws(() => authorizeGrokBrokerProxyRequest({ method: "GET", pathname: "/", headers: { authorization: `Bearer ${opaque}` }, body, agentId: "a", turnId: "t" }, caps, "real-bearer"), /rejected/); +}); diff --git a/src/runtime/grokBrokerProxyRequest.ts b/src/runtime/grokBrokerProxyRequest.ts new file mode 100644 index 0000000..d75d71c --- /dev/null +++ b/src/runtime/grokBrokerProxyRequest.ts @@ -0,0 +1,16 @@ +import type { EngineBrokerCapabilities } from "./engineBrokerCapabilities.js"; + +const MAX_BODY = 2 * 1024 * 1024; +export type GrokBrokerProxyInput = Readonly<{ method: string; pathname: string; headers: Readonly>; body: Uint8Array; agentId?: string; turnId?: string }>; +export type GrokBrokerUpstreamRequest = Readonly<{ url: "https://cli-chat-proxy.grok.com/v1/chat/completions"; headers: Readonly>; body: Uint8Array }>; + +export function authorizeGrokBrokerProxyRequest(input: GrokBrokerProxyInput, capabilities: EngineBrokerCapabilities, bearer: string): GrokBrokerUpstreamRequest { + if (input.method !== "POST" || input.pathname !== "/v1/chat/completions" || input.body.byteLength < 2 || input.body.byteLength > MAX_BODY) throw new Error("broker proxy request rejected"); + const authorization = input.headers.authorization; const match = authorization?.match(/^Bearer ([A-Za-z0-9_-]{40,})$/u); + if (match === null || match === undefined) throw new Error("broker proxy request rejected"); + const scope = capabilities.authorizeToken(match[1]!); + if (scope === undefined || (input.agentId !== undefined && scope.agentId !== input.agentId) || (input.turnId !== undefined && scope.turnId !== input.turnId)) throw new Error("broker proxy request rejected"); + if (!bearer || /[\r\n]/u.test(bearer)) throw new Error("broker credential authority unavailable"); + try { const parsed = JSON.parse(Buffer.from(input.body).toString("utf8")) as Record; if (parsed.stream !== true || !Array.isArray(parsed.messages)) throw new Error(); } catch { throw new Error("broker proxy request rejected"); } + return { url: "https://cli-chat-proxy.grok.com/v1/chat/completions", headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json", "x-xai-token-auth": "xai-grok-cli", "x-grok-model-override": "grok-build" }, body: input.body }; +} diff --git a/src/runtime/grokBrokerRealmLease.test.ts b/src/runtime/grokBrokerRealmLease.test.ts new file mode 100644 index 0000000..4d86a9b --- /dev/null +++ b/src/runtime/grokBrokerRealmLease.test.ts @@ -0,0 +1,9 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp,rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { acquireGrokBrokerRealmLease } from "./grokBrokerRealmLease.js"; + +test("broker realm lease excludes a second broker and releases on close",{skip:spawnSync("flock",["--version"]).error!==undefined},async()=>{const root=await mkdtemp(path.join(os.tmpdir(),"grok-broker-lease-"));try{const first=await acquireGrokBrokerRealmLease(root);await assert.rejects(acquireGrokBrokerRealmLease(root),/already in use/u);await first.close();const replacement=await acquireGrokBrokerRealmLease(root);await replacement.close();}finally{await rm(root,{recursive:true,force:true});}}); diff --git a/src/runtime/grokBrokerRealmLease.ts b/src/runtime/grokBrokerRealmLease.ts new file mode 100644 index 0000000..f96eaa6 --- /dev/null +++ b/src/runtime/grokBrokerRealmLease.ts @@ -0,0 +1,11 @@ +import { spawn,type ChildProcess } from "node:child_process"; +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import path from "node:path"; +import { terminateChild,trackCliChild } from "../pi/cliProcess.js"; + +export async function acquireGrokBrokerRealmLease(root:string,flock="flock"):Promise}>>{ + const handle=await open(path.join(root,".daimon-broker-lease"),constants.O_CREAT|constants.O_RDWR|constants.O_NOFOLLOW,0o600);let child:ChildProcess|undefined; + try{const stat=await handle.stat();if(!stat.isFile()||stat.uid!==process.getuid?.()||stat.nlink!==1||(stat.mode&0o777)!==0o600)throw new Error();child=trackCliChild(spawn("/bin/sh",["-c",'"$1" --exclusive --nonblock --conflict-exit-code 73 3 || exit 73; printf "ready\\n"; IFS= read -r _hold || :',"daimon-grok-broker-lease",flock],{detached:process.platform!=="win32",env:{PATH:process.env.PATH,LANG:"C",LC_ALL:"C",TZ:"UTC"},stdio:["pipe","pipe","ignore",handle.fd]}));await ready(child);let closed=false;return{close:async()=>{if(closed)return;closed=true;const exited=new Promise((resolve)=>{if(child!.exitCode!==null||child!.signalCode!==null)return resolve(true);child!.once("close",()=>resolve(true));setTimeout(()=>resolve(false),500);});child!.stdin?.end();if(!await exited)await terminateChild(child!);}};}catch{if(child)await terminateChild(child).catch(()=>undefined);throw new Error("Grok broker realm is already in use or cannot be leased");}finally{await handle.close();} +} +function ready(child:ChildProcess):Promise{return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error()),5000);const fail=()=>{clearTimeout(timer);reject(new Error());};child.once("error",fail);child.once("close",fail);child.stdout?.once("data",(chunk:Buffer)=>{clearTimeout(timer);if(chunk.toString()==="ready\n")resolve();else reject(new Error());});});} diff --git a/src/runtime/grokBrokerRefresh.test.ts b/src/runtime/grokBrokerRefresh.test.ts new file mode 100644 index 0000000..20f870f --- /dev/null +++ b/src/runtime/grokBrokerRefresh.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { refreshGrokBrokerCredential } from "./grokBrokerRefresh.js"; + +test("refresh uses only pinned models command and a positive broker environment", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-refresh-")); const command = path.join(root, "grok"); const receipt = path.join(root, "receipt.json"); + await writeFile(command, `#!/bin/sh\n[ "$1" = models ] || exit 9\nnode -e 'require("fs").writeFileSync(process.argv[1],JSON.stringify({argv:process.argv.slice(2),expired:process.env.GROK_AUTH_EXPIRED,secret:process.env.SHOULD_NOT_LEAK}))' '${receipt}'\n`); await chmod(command, 0o700); + process.env.SHOULD_NOT_LEAK = "secret"; + try { await refreshGrokBrokerCredential(command, root); const value = JSON.parse(await readFile(receipt, "utf8")); assert.deepEqual(value, { argv: [] }); } + finally { delete process.env.SHOULD_NOT_LEAK; await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/runtime/grokBrokerRefresh.ts b/src/runtime/grokBrokerRefresh.ts new file mode 100644 index 0000000..ee0fe5e --- /dev/null +++ b/src/runtime/grokBrokerRefresh.ts @@ -0,0 +1,10 @@ +import { spawn } from "node:child_process"; +import { readChild } from "../pi/cliChildOutput.js"; +import { terminateChild, trackCliChild } from "../pi/cliProcess.js"; + +export async function refreshGrokBrokerCredential(command: string, grokHome: string, timeoutMs = 15_000): Promise { + const child = trackCliChild(spawn(command, ["models"], { detached: process.platform !== "win32", env: { GROK_HOME: grokHome, HOME: grokHome, PATH: process.env.PATH, LANG: "C", LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "pipe"] })); + try { await readChild(child, timeoutMs, []); } + catch { throw new Error("Grok broker credential refresh failed"); } + finally { await terminateChild(child).catch(() => undefined); } +} diff --git a/src/runtime/grokBrokerWorkerConfig.test.ts b/src/runtime/grokBrokerWorkerConfig.test.ts new file mode 100644 index 0000000..0be5567 --- /dev/null +++ b/src/runtime/grokBrokerWorkerConfig.test.ts @@ -0,0 +1,8 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderGrokBrokerWorkerArgs, renderGrokBrokerWorkerConfig } from "./grokBrokerWorkerConfig.js"; +test("worker config uses only named in-memory auth and fixed loopback proxy", () => { + const config = renderGrokBrokerWorkerConfig("/opt/daimon/bin/grok-broker-auth", 43123); + assert.match(config, /auth_provider\.daimon/u); assert.match(config, /args = \["--auth-provider"\]/u);assert.match(config, /127\.0\.0\.1:43123/u);assert.match(config,/127\.0\.0\.1:43124\/mcp/u);assert.match(config,/DAIMON_MCP_CAPABILITY/u); assert.doesNotMatch(config, /access_token|refresh_token|auth\.json/u); + const args = renderGrokBrokerWorkerArgs("/run/worker/prompt", "/workspace"); assert.equal(args.includes("--prompt-file"), true); assert.equal(args.includes("--single"), false); +}); diff --git a/src/runtime/grokBrokerWorkerConfig.ts b/src/runtime/grokBrokerWorkerConfig.ts new file mode 100644 index 0000000..4223490 --- /dev/null +++ b/src/runtime/grokBrokerWorkerConfig.ts @@ -0,0 +1,17 @@ +import path from "node:path"; + +export function renderGrokBrokerWorkerConfig(helperPath: string, proxyPort: number): string { + if (!path.posix.isAbsolute(helperPath) || /[\r\n"']/u.test(helperPath) || !Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65_535) throw new TypeError("invalid Grok broker worker configuration"); + return [ + "[cli]", "auto_update = false", "use_leader = false", "", + "[features]", "telemetry = false", "", + "[auth_provider.daimon]", `command = ${JSON.stringify(helperPath)}`, 'args = ["--auth-provider"]', "timeout_secs = 5", "token_ttl_secs = 600", "", + "[model.daimon-broker-grok]", 'model = "grok-build"', `base_url = "http://127.0.0.1:${proxyPort}/v1"`, 'auth_provider = "daimon"', "context_window = 131072", "supports_backend_search = false", "", + "[mcp_servers.daimon]", 'url = "http://127.0.0.1:43124/mcp"', 'headers = { Authorization = "Bearer ${DAIMON_MCP_CAPABILITY}" }', "" + ].join("\n"); +} + +export const renderGrokBrokerWorkerArgs = (promptFile: string, cwd: string): readonly string[] => { + if (!path.posix.isAbsolute(promptFile) || !path.posix.isAbsolute(cwd)) throw new TypeError("invalid Grok broker worker path"); + return ["--sandbox", "daimon-strict", "--always-approve", "--no-subagents", "--prompt-file", promptFile, "--no-memory", "--disable-web-search", "--cwd", cwd, "--output-format", "streaming-messages-json", "--model", "daimon-broker-grok"]; +}; diff --git a/src/runtime/grokEngineBroker.ts b/src/runtime/grokEngineBroker.ts new file mode 100644 index 0000000..65d251f --- /dev/null +++ b/src/runtime/grokEngineBroker.ts @@ -0,0 +1,31 @@ +import { createHash, randomUUID } from "node:crypto"; +import { decodeGrokHeadlessResult } from "../pi/grokHeadlessResult.js"; +import { DurableGrokBrokerCredentialAuthority } from "./grokBrokerCredentialAuthority.js"; +import { NativeBrokerTurnFailure, runNativeBrokerTurn, type NativeBrokerDiagnostic } from "./engineBrokerNativeClient.js"; +import { EngineBrokerTurnRegistry } from "./engineBrokerTurnRegistry.js"; +import { startGrokBrokerProxy } from "./grokBrokerProxy.js"; +import { startEngineBrokerMcpFacade } from "./engineBrokerMcpFacade.js"; +import { acquireGrokBrokerRealmLease } from "./grokBrokerRealmLease.js"; +import { GrokWorkerAttestationFailure,prepareGrokWorkerAttestation,verifyGrokWorkerAttestation } from "./grokWorkerAttestation.js"; + +export type GrokEngineBrokerRegistration = Readonly<{ agentId:string;slot:number;workerUid:number;workspace:string;profilePath:string;eventsPath:string;profileSha256:string }>; +export type GrokEngineBroker = Awaited>; +export class EngineBrokerTurnFailure extends Error{constructor(readonly code:"auth_stale"|"cancelled"|"engine_failed",readonly diagnostic?:NativeBrokerDiagnostic){super("engine broker turn failed");}} +export async function startGrokEngineBroker(options: Readonly<{ grokCommand: string; nativeClient: string; credentialHome: string; turnStore: string; registrations: readonly GrokEngineBrokerRegistration[] }>) { + const registrations = new Map(options.registrations.map((entry) => [entry.agentId, entry])); if (registrations.size !== options.registrations.length) throw new Error("engine broker registration conflict"); + const lease=await acquireGrokBrokerRealmLease(options.credentialHome);const authority = new DurableGrokBrokerCredentialAuthority(options.grokCommand, options.credentialHome);try{await authority.initialize();}catch(error){await lease.close();throw error;} let proxy:Awaited>;try{proxy=await startGrokBrokerProxy(authority);}catch(error){await lease.close();throw error;}let mcp:Awaited>|undefined;try{mcp=await startEngineBrokerMcpFacade();for(const registration of registrations.values())await prepareGrokWorkerAttestation({...registration,brokerGid:2100});}catch(error){if(mcp)await mcp.close().catch(()=>undefined);await proxy.close();await lease.close();throw error;}if(!mcp)throw new Error("engine broker unavailable");const turns = new EngineBrokerTurnRegistry(options.turnStore); const active = new Map; resolve: () => void }>(); let closed = false; + return { + async turn(agentId: string, wakeId: string, prompt: string, mcpEndpoint: string, signal?: AbortSignal): Promise> { + if (closed) throw new Error("engine broker unavailable"); const registration = registrations.get(agentId); if (registration === undefined) throw new Error("engine broker unavailable"); + const turnId = createHash("sha256").update(`${agentId}\0${wakeId}`).digest("hex"); const request = { version: "noopolis.daimon.engine-broker.v1", kind: "start_turn", requestId: randomUUID(), turnId, agentId, wakeId, prompt,mcpEndpoint } as const; + const begun = await turns.begin(request); if (begun !== "start") { if (begun.replay.kind === "completed") return {text:begun.replay.text,workerPid:begun.replay.workerPid,workerUid:begun.replay.workerUid,workerStartTime:begun.replay.workerStartTime};const code=begun.replay.code==="auth_stale"||begun.replay.code==="cancelled"?begun.replay.code:"engine_failed";throw new EngineBrokerTurnFailure(code,begun.replay.diagnostic as NativeBrokerDiagnostic|undefined); } + const isolation=await prepareGrokWorkerAttestation({...registration,brokerGid:2100});proxy.registerIsolationGuard(turnId,()=>verifyGrokWorkerAttestation({...registration,brokerGid:2100},isolation));const providerCapability = proxy.capabilities.issue(agentId, turnId);const mcpCapability=mcp.register(agentId,turnId,mcpEndpoint); const controller = new AbortController();const onAbort=()=>controller.abort();signal?.addEventListener("abort",onAbort,{once:true});if(signal?.aborted)controller.abort(); let resolve!:()=>void;const done=new Promise((value)=>{resolve=value;});active.set(turnId,{controller,done,resolve}); + let nativeDiagnostic:NativeBrokerDiagnostic|undefined,attested=false; + try { const result = await runNativeBrokerTurn(options.nativeClient, { slot: registration.slot, requestId: request.requestId, turnId, agentId, wakeId, prompt, providerCapability,mcpCapability }, controller.signal);nativeDiagnostic=result.diagnostic;if(result.workerUid!==registration.workerUid)throw new Error();await verifyGrokWorkerAttestation({...registration,brokerGid:2100},isolation);attested=true; const text = decodeGrokHeadlessResult(result.text);const completed={ version: request.version, kind: "completed", requestId: request.requestId, turnId, text, workerPid: result.workerPid, workerUid: result.workerUid, workerStartTime: result.startTicks.toString() } as const; await turns.finish(request,completed); return {text,workerPid:result.workerPid,workerUid:result.workerUid,workerStartTime:result.startTicks.toString()}; } + catch(error) { const code=authority.isStale()?"auth_stale":controller.signal.aborted?"cancelled":"engine_failed";const diagnostic=error instanceof NativeBrokerTurnFailure?error.diagnostic:nativeDiagnostic&&!attested?{...nativeDiagnostic,status:"worker_failed" as const,stage:"attestation" as const,failureClass:error instanceof GrokWorkerAttestationFailure?error.failureClass:"profile_invalid" as const,profileApplied:false}:undefined;await turns.finish(request, { version: request.version, kind: "failed", requestId: request.requestId, turnId, code,...(diagnostic?{diagnostic}:{}) }); throw new EngineBrokerTurnFailure(code,diagnostic); } + finally { signal?.removeEventListener("abort",onAbort);active.get(turnId)?.resolve(); active.delete(turnId); proxy.revokeIsolationGuard(turnId);proxy.capabilities.revoke(turnId);mcp.revoke(turnId); } + }, + async close(): Promise { if (closed) return; closed = true; const running=[...active.values()];for (const entry of running) entry.controller.abort();await Promise.allSettled(running.map((entry)=>entry.done));const results=await Promise.allSettled([mcp.close(),proxy.close(),lease.close()]);const failures=results.flatMap((entry)=>entry.status==="rejected"?[entry.reason]:[]);if(failures.length)throw new AggregateError(failures,"engine broker shutdown failed"); }, + readiness: () => ({ providerProxyPort: proxy.port, mcpFacadePort:43_124, registrations: registrations.size,credentialStale:authority.isStale(),realmLease:true,workerIsolation:true }) + }; +} diff --git a/src/runtime/grokWorkerAttestation.test.ts b/src/runtime/grokWorkerAttestation.test.ts new file mode 100644 index 0000000..4173bcd --- /dev/null +++ b/src/runtime/grokWorkerAttestation.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseGrokWorkerProfileApplied } from "./grokWorkerAttestation.js"; + +const workspace="/var/lib/daimon-workers/2200/workspace"; +const event=(overrides:Record={})=>Buffer.from(`${JSON.stringify({event_type:"ProfileApplied",profile:"daimon-strict",enforced:true,restrict_network:true,platform:"linux/landlock",workspace,...overrides})}\n`); + +test("accepts only exact enforced cognition-worker profile evidence",()=>{ + assert.doesNotThrow(()=>parseGrokWorkerProfileApplied(event(),workspace)); + for(const invalid of [{enforced:false},{restrict_network:false},{platform:"darwin"},{profile:"strict"},{workspace:"/peer"}])assert.throws(()=>parseGrokWorkerProfileApplied(event(invalid),workspace),/attestation unavailable/u); +}); + +test("requires a complete final ProfileApplied event",()=>{ + assert.throws(()=>parseGrokWorkerProfileApplied(Buffer.from('{"event_type":"ProfileApplied"'),workspace),/attestation unavailable/u); + assert.throws(()=>parseGrokWorkerProfileApplied(Buffer.from('{}\n'),workspace),/attestation unavailable/u); +}); diff --git a/src/runtime/grokWorkerAttestation.ts b/src/runtime/grokWorkerAttestation.ts new file mode 100644 index 0000000..93f2694 --- /dev/null +++ b/src/runtime/grokWorkerAttestation.ts @@ -0,0 +1,15 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat,open } from "node:fs/promises"; + +type Snapshot=Readonly<{dev:number;ino:number;size:number;mtimeMs:number;denyPaths:readonly string[]}>; +export class GrokWorkerAttestationFailure extends Error { constructor(readonly failureClass:"profile_missing"|"profile_invalid"){super("Grok worker isolation attestation unavailable");} } +export async function prepareGrokWorkerAttestation(input:Readonly<{profilePath:string;eventsPath:string;profileSha256:string;workerUid:number;brokerGid:number}>):Promise{ + const profile=await secureOpen(input.profilePath,0,0,0o444,65_536);let bytes:Buffer|undefined;let denyPaths:readonly string[]=[];try{bytes=await profile.readFile();if(createHash("sha256").update(bytes).digest("hex")!==input.profileSha256)throw new Error();const line=bytes.toString("utf8").split("\n").find((entry)=>entry.startsWith("deny = "));const parsed=JSON.parse(line?.slice(7)??"null") as unknown;if(!Array.isArray(parsed)||parsed.length<3||parsed.some((entry)=>typeof entry!=="string")||new Set(parsed).size!==parsed.length)throw new Error();denyPaths=[...parsed].sort();}catch{throw new Error("Grok worker isolation attestation unavailable");}finally{bytes?.fill(0);await profile.close();} + const events=await secureOpen(input.eventsPath,input.workerUid,input.brokerGid,0o640,16*1024*1024);try{const stat=await events.stat();return{dev:Number(stat.dev),ino:Number(stat.ino),size:Number(stat.size),mtimeMs:Number(stat.mtimeMs),denyPaths};}finally{await events.close();} +} +export async function verifyGrokWorkerAttestation(input:Readonly<{eventsPath:string;workerUid:number;brokerGid:number;workspace:string}>,before:Snapshot):Promise{ + let handle:Awaited>;try{handle=await secureOpen(input.eventsPath,input.workerUid,input.brokerGid,0o640,16*1024*1024);}catch{throw new GrokWorkerAttestationFailure("profile_invalid");}let bytes:Buffer|undefined;try{const stat=await handle.stat();if(Number(stat.dev)!==before.dev||Number(stat.ino)!==before.ino)throw new GrokWorkerAttestationFailure("profile_invalid");if(Number(stat.size)<=before.size)throw new GrokWorkerAttestationFailure("profile_missing");bytes=Buffer.alloc(Number(stat.size)-before.size);const read=await handle.read(bytes,0,bytes.length,before.size);if(read.bytesRead!==bytes.length)throw new GrokWorkerAttestationFailure("profile_invalid");const after=await handle.stat();if(Number(after.size)!==Number(stat.size)||Number(after.mtimeMs)!==Number(stat.mtimeMs))throw new GrokWorkerAttestationFailure("profile_invalid");parseGrokWorkerProfileApplied(bytes,input.workspace,before.denyPaths);}catch(error){if(error instanceof GrokWorkerAttestationFailure)throw error;throw new GrokWorkerAttestationFailure("profile_invalid");}finally{bytes?.fill(0);await handle.close();} +} +export function parseGrokWorkerProfileApplied(bytes:Uint8Array,workspace:string,denyPaths:readonly string[]=[]):void{try{const lines=Buffer.from(bytes).toString("utf8").trim().split("\n");const event=JSON.parse(lines.at(-1)??"null") as Record|null;const observed=Array.isArray(event?.deny_paths)?event.deny_paths.filter((entry):entry is string=>typeof entry==="string").sort():[];if(event?.event_type!=="ProfileApplied"||event.profile!=="daimon-strict"||event.enforced!==true||event.restrict_network!==true||event.platform!=="linux/landlock"||event.workspace!==workspace||JSON.stringify(observed)!==JSON.stringify([...denyPaths].sort()))throw new Error();}catch{throw new Error("Grok worker isolation attestation unavailable");}} +async function secureOpen(file:string,uid:number,gid:number,mode:number,max:number){try{const before=await lstat(file);const handle=await open(file,constants.O_RDONLY|constants.O_NOFOLLOW);const stat=await handle.stat();if(!stat.isFile()||stat.isSymbolicLink()||Number(stat.uid)!==uid||Number(stat.gid)!==gid||(Number(stat.mode)&0o777)!==mode||Number(stat.nlink)!==1||Number(stat.size)>max||before.dev!==stat.dev||before.ino!==stat.ino){await handle.close();throw new Error();}return handle;}catch{throw new Error("Grok worker isolation attestation unavailable");}} From b7ae47672f824988c71aa7886eb1fe3aa4841730 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:45 +0200 Subject: [PATCH 06/24] feat(runtime): route engines through scoped production capabilities --- src/pi/piAgentHandle.ts | 18 +++- src/pi/piAgentWakeSupport.ts | 4 + src/pi/piHarness.ts | 33 ++++++-- src/pi/piHarnessWakeEnvironment.test.ts | 47 +++++++++++ src/runtime/engineDispatcher.test.ts | 73 ++++++++++++++-- src/runtime/engineDispatcher.ts | 71 +++++++++++++--- src/runtime/engineReadiness.test.ts | 63 +++++++++++++- src/runtime/engineReadiness.ts | 85 ++++++++++--------- src/runtime/organizationRuntimeReadiness.ts | 79 +++++++++++++++++ src/runtime/productionAgentTools.test.ts | 41 +++++++++ src/runtime/productionAgentTools.ts | 93 +++++++++++++++++++++ 11 files changed, 540 insertions(+), 67 deletions(-) create mode 100644 src/pi/piHarnessWakeEnvironment.test.ts create mode 100644 src/runtime/organizationRuntimeReadiness.ts create mode 100644 src/runtime/productionAgentTools.test.ts create mode 100644 src/runtime/productionAgentTools.ts diff --git a/src/pi/piAgentHandle.ts b/src/pi/piAgentHandle.ts index 242bd97..5593f56 100644 --- a/src/pi/piAgentHandle.ts +++ b/src/pi/piAgentHandle.ts @@ -28,6 +28,7 @@ import { type PiSession, type PiSessionCreator, type PiSessionLike, + type PiWakeEnvironmentContextRef, type WakeSessionSelection } from "./piAgentWakeSupport.js"; import { readMemoryContext, type MemoryPrepareTurnResult, type MemoryRuntime } from "@noopolis/mneme"; @@ -54,7 +55,8 @@ export class PiAgentHandle implements AgentHandle { rawTrainingCaptureRef?: PiRawTrainingCaptureRef, rawTrainingCaptureOptions?: PiRawTrainingCaptureOptions, worldTrajectoryIdentity?: PiWorldTrajectoryIdentity, - rawTrainingCaptureSession?: PiSession + rawTrainingCaptureSession?: PiSession, + wakeEnvironmentContext?: PiWakeEnvironmentContextRef ); constructor( id: string, @@ -69,7 +71,8 @@ export class PiAgentHandle implements AgentHandle { rawTrainingCaptureRef?: never, rawTrainingCaptureOptions?: never, worldTrajectoryIdentity?: PiWorldTrajectoryIdentity, - rawTrainingCaptureSession?: never + rawTrainingCaptureSession?: never, + wakeEnvironmentContext?: PiWakeEnvironmentContextRef ); constructor( readonly id: string, @@ -84,7 +87,8 @@ export class PiAgentHandle implements AgentHandle { private readonly rawTrainingCaptureRef?: PiRawTrainingCaptureRef, private readonly rawTrainingCaptureOptions?: PiRawTrainingCaptureOptions, private readonly worldTrajectoryIdentity?: PiWorldTrajectoryIdentity, - private readonly piSessionForRawCapture?: PiSession + private readonly piSessionForRawCapture?: PiSession, + private readonly wakeEnvironmentContext?: PiWakeEnvironmentContextRef ) { this.stampTurnInputSubmitted = dependencies.runWake ?? stampTurnInputSubmitted; this.stampTurnOutputCompleted = dependencies.completeTurn ?? stampTurnOutputCompleted; @@ -149,6 +153,9 @@ export class PiAgentHandle implements AgentHandle { ? formatWakePrompt(event) : safeWakeText; try { + if (this.wakeEnvironmentContext !== undefined) { + this.wakeEnvironmentContext.current = event.id; + } if (this.worldToolContext !== undefined) { this.worldToolContext.current = worldContext; } @@ -220,6 +227,7 @@ export class PiAgentHandle implements AgentHandle { } stage = "engine_prompt"; const engineStartedAt = Date.now(); + selectedSession.session.bindWake?.(event); await selectedSession.session.prompt(promptText, { expandPromptTemplates: false }); enginePromptMs = Date.now() - engineStartedAt; @@ -344,6 +352,10 @@ export class PiAgentHandle implements AgentHandle { throw error; } finally { + selectedSession?.session.bindWake?.(); + if (this.wakeEnvironmentContext !== undefined) { + this.wakeEnvironmentContext.current = undefined; + } if (this.memoryToolContext !== undefined) { this.memoryToolContext.current = undefined; this.memoryToolContext.observeTool = undefined; diff --git a/src/pi/piAgentWakeSupport.ts b/src/pi/piAgentWakeSupport.ts index 73dae5c..b96f769 100644 --- a/src/pi/piAgentWakeSupport.ts +++ b/src/pi/piAgentWakeSupport.ts @@ -37,12 +37,16 @@ export type PiSession = Awaited>["session" export interface PiSessionLike { subscribe(listener: Parameters[0]): () => void; prompt(text: string, options?: Parameters[1]): Promise; + /** Optional per-turn context for isolated CLI child environments. */ + bindWake?(event?: WakeEvent): void; /** Kept synchronous for existing Pi session implementations. */ dispose(): void; /** Optional additive hook for sessions that must asynchronously quiesce children. */ disposeAsync?(): Promise; } +export type PiWakeEnvironmentContextRef = { current?: string }; + export async function disposePiSession(session: PiSessionLike): Promise { await Promise.resolve(session.dispose()); await session.disposeAsync?.(); diff --git a/src/pi/piHarness.ts b/src/pi/piHarness.ts index 0a84f09..0c88a65 100644 --- a/src/pi/piHarness.ts +++ b/src/pi/piHarness.ts @@ -19,6 +19,8 @@ import { createPiModelRegistry } from "./modelRegistry.js"; import { createPiMemoryTools, piMemoryToolNames, type PiMemoryToolContextRef } from "./memoryTools.js"; import { createResourceLoader } from "./prompts.js"; import { PiAgentHandle, type PiNativeSessionCreator, type PiSessionCreator, type PiSessionLike } from "./piAgentHandle.js"; +import { type PiWakeEnvironmentContextRef } from "./piAgentWakeSupport.js"; +import { DAIMON_WAKE_ID_ENV } from "./cliEnvironment.js"; import { createPiWorldTools, piWorldToolNames, type PiWorldBinding } from "./worldTools.js"; import type { PiWorldToolContextRef } from "./worldNudge.js"; import { @@ -57,6 +59,8 @@ type PiHarnessBaseOptions = { }; thinkingLevel?: PiThinkingLevel; world?: PiWorldBinding; + productionTools?: readonly ToolDefinition[]; + wakeEnvironmentContext?: PiWakeEnvironmentContextRef; }; export type PiHarnessOptions = PiHarnessBaseOptions & ( @@ -97,7 +101,8 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { `${input.runtimeHomePath}/.local/share`, `${input.runtimeHomePath}/.local/state`, `${input.runtimeHomePath}/.cache`, - `${input.runtimeHomePath}/.tmp` + `${input.runtimeHomePath}/.tmp`, + `${input.runtimeHomePath}/tool-state` ].map((directory) => mkdir(directory, { recursive: true }))); await mkdir(input.workspacePath, { recursive: true }); const memoryRuntimeHomePath = this.options.memory?.runtimeHomePath ?? input.runtimeHomePath; @@ -130,6 +135,7 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { this.options.world === undefined ? undefined : {}; const rawTrainingCaptureRef: PiRawTrainingCaptureRef | undefined = this.options.rawTrainingCapture === undefined ? undefined : {}; + const wakeEnvironmentContext: PiWakeEnvironmentContextRef = this.options.wakeEnvironmentContext ?? {}; const sessionInput = (mode: Parameters[0], sessionDirectory: string) => { const memoryTools = memory === undefined || memoryToolContext === undefined ? [] @@ -153,11 +159,12 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { ])]; const protectedBash = protectedNames.length === 0 || input.tools?.includes("bash") === false ? [] - : [createProtectedBashTool(input.workspacePath, input.runtimeHomePath, protectedNames)]; + : [createProtectedBashTool(input.workspacePath, input.runtimeHomePath, protectedNames, wakeEnvironmentContext)]; const toolNames = [ ...(input.tools ?? ["read", "write", "edit", "bash", "grep", "find", "ls"]), ...piMemoryToolNames(memoryTools), ...(worldTools === undefined ? [] : piWorldToolNames(worldTools)) + ,...(this.options.productionTools ?? []).map((tool) => tool.name) ]; return { @@ -175,8 +182,8 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { }), tools: [...new Set(toolNames)], customTools: worldTools === undefined - ? [...protectedBash, ...memoryTools] - : [...protectedBash, ...memoryTools, ...worldTools], + ? [...protectedBash, ...memoryTools, ...(this.options.productionTools ?? [])] + : [...protectedBash, ...memoryTools, ...worldTools, ...(this.options.productionTools ?? [])], sessionManager: SessionManager.create(input.workspacePath, sessionDirectory), settingsManager: SettingsManager.inMemory({ compaction: { enabled: false }, @@ -213,7 +220,8 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { worldToolContext === undefined ? undefined : { instructions: input.instructions, thinkingLevel: this.options.thinkingLevel ?? "off" }, - session + session, + wakeEnvironmentContext ); } @@ -245,12 +253,18 @@ export class PiHarnessAdapter implements AgentHarnessAdapter { instructions: input.instructions, thinkingLevel: this.options.thinkingLevel ?? "off" }, - undefined + undefined, + wakeEnvironmentContext ); } } -function createProtectedBashTool(workspacePath: string, runtimeHomePath: string, protectedNames: readonly string[]): ToolDefinition { +function createProtectedBashTool( + workspacePath: string, + runtimeHomePath: string, + protectedNames: readonly string[], + wakeEnvironmentContext: PiWakeEnvironmentContextRef +): ToolDefinition { const bash = createBashTool(workspacePath, { spawnHook: (context) => ({ ...context, @@ -261,7 +275,10 @@ function createProtectedBashTool(workspacePath: string, runtimeHomePath: string, XDG_DATA_HOME: `${runtimeHomePath}/.local/share`, XDG_STATE_HOME: `${runtimeHomePath}/.local/state`, XDG_CACHE_HOME: `${runtimeHomePath}/.cache`, - TMPDIR: `${runtimeHomePath}/.tmp` + TMPDIR: `${runtimeHomePath}/.tmp`, + ...(wakeEnvironmentContext.current === undefined + ? {} + : { [DAIMON_WAKE_ID_ENV]: wakeEnvironmentContext.current }) } }) }); diff --git a/src/pi/piHarnessWakeEnvironment.test.ts b/src/pi/piHarnessWakeEnvironment.test.ts new file mode 100644 index 0000000..e450c3b --- /dev/null +++ b/src/pi/piHarnessWakeEnvironment.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { PiHarnessAdapter, type PiSessionFactory } from "./piHarness.js"; + +test("binds the current wake id to protected bash children and clears it after the turn", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-wake-env-")); + const protectedName = "DAIMON_WAKE_ENV_CONTROL_CANARY"; + const priorRun = process.env.NOOPOLIS_RUN_ID; + process.env.NOOPOLIS_RUN_ID = "wake-environment-test"; + process.env[protectedName] = "secret"; + let bash: NonNullable[0]["customTools"]>[number] | undefined; + let during = ""; + const factory: PiSessionFactory = async (input) => { + bash = input.customTools?.find((tool) => tool.name === "bash"); + return { session: { + async prompt() { + during = JSON.stringify(await bash!.execute("wake-env", { command: "printf %s \"$DAIMON_WAKE_ID\"" }, undefined, undefined, {} as never)); + }, + subscribe() { return () => undefined; }, + dispose() { return undefined; } + } }; + }; + try { + const adapter = new PiHarnessAdapter({ + authPath: path.join(root, "auth.json"), + model: { auth: { method: "none" }, endpoint: { baseUrl: "http://127.0.0.1", compatibility: "openai" }, name: "stub", provider: "stub" }, + protectedEnvironmentNames: [protectedName], + sessionFactory: factory + }); + const handle = await adapter.startAgent({ id: "agent", name: "Agent", instructions: "test", workspacePath: root, runtimeHomePath: path.join(root, "home") }); + await handle.wake({ id: "moltnet:msg_1", kind: "message", text: "hello" }); + assert.match(during, /moltnet:msg_1/u); + const after = JSON.stringify(await bash!.execute("wake-env-after", { command: "printf %s \"${DAIMON_WAKE_ID-unset}\"" }, undefined, undefined, {} as never)); + assert.match(after, /unset/u); + assert.doesNotMatch(after, /moltnet:msg_1/u); + await handle.stop(); + } finally { + delete process.env[protectedName]; + if (priorRun === undefined) delete process.env.NOOPOLIS_RUN_ID; + else process.env.NOOPOLIS_RUN_ID = priorRun; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/runtime/engineDispatcher.test.ts b/src/runtime/engineDispatcher.test.ts index 21671d4..dd7f8f8 100644 --- a/src/runtime/engineDispatcher.test.ts +++ b/src/runtime/engineDispatcher.test.ts @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; -import { access, chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, chmod, mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { startOrganizationRuntimeEngine } from "./engineDispatcher.js"; +import { grokSandboxProtectedPaths, startOrganizationRuntimeEngine } from "./engineDispatcher.js"; +import { AGY_SUBSCRIPTION_REALM, GROK_SUBSCRIPTION_REALM } from "./contractManifest.js"; +import type { EngineBrokerTurnClient } from "./engineBrokerControlClient.js"; import { ORGANIZATION_RUNTIME_VERSION, type OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; const rootConfig = (root: string, kind: OrganizationRuntimeAgentConfig["engine"]["kind"]): OrganizationRuntimeAgentConfig => ({ @@ -13,11 +15,35 @@ const rootConfig = (root: string, kind: OrganizationRuntimeAgentConfig["engine"] engine: { kind } }); +test("Grok sandbox protects the shared realm and every peer agent root", () => { + const current = rootConfig("/private/org", "grok"); + const peer: OrganizationRuntimeAgentConfig = { + ...rootConfig("/private/org", "codex"), + id: "peer-agent" + }; + const agy: OrganizationRuntimeAgentConfig = { + ...rootConfig("/private/org", "agy"), + id: "secure-agent" + }; + const acceptanceStore = "/private/org/shared/wake-acceptance"; + assert.deepEqual(grokSandboxProtectedPaths(current.id, [current, peer, agy], [acceptanceStore]), [ + GROK_SUBSCRIPTION_REALM.bootstrapMountPath, + GROK_SUBSCRIPTION_REALM.durableMountPath, + AGY_SUBSCRIPTION_REALM.unlockMountPath, + AGY_SUBSCRIPTION_REALM.durableMountPath, + acceptanceStore, + peer.runtimeHomePath, + peer.workspacePath, + agy.runtimeHomePath, + agy.workspacePath + ]); +}); + test("production dispatcher starts each closed engine intent through Daimon", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-dispatcher-")); const priorPath = process.env.PATH; const priorRun = process.env.NOOPOLIS_RUN_ID; - const stub = `#!/usr/bin/env node\nconst args = process.argv.slice(2); if (args.includes("mcp")) process.stdout.write("ok"); else process.stdout.write(process.env.DAIMON_DISPATCH_CONTROL ?? "absent");`; + const stub = `#!/usr/bin/env node\nconst args = process.argv.slice(2); const text = process.env.DAIMON_DISPATCH_CONTROL ?? "absent"; const stream = (value) => [{ type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: value }] } }, { type: "result", subtype: "success", is_error: false, result: value, stop_reason: "end_turn", session_id: "fake" }].map(JSON.stringify).join("\\n"); if (args.includes("mcp")) process.stdout.write("ok"); else process.stdout.write(args.includes("--single") ? stream(text) : text);`; try { for (const name of ["codex", "grok", "agy"]) { const file = path.join(root, name); @@ -73,6 +99,37 @@ test("production dispatcher waits for active engine quiescence during shutdown", } }); +test("production Grok dispatcher routes every wake through the broker without agent credentials", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-dispatcher-grok-realm-")); + const priorPath = process.env.PATH; + const priorRun = process.env.NOOPOLIS_RUN_ID; + const executable = path.join(root, "grok"); + const config = rootConfig(root, "grok") as OrganizationRuntimeAgentConfig & { engine: { kind: "grok" } }; + const auth = path.join(config.runtimeHomePath, ".grok", "auth.json"); + let turns = 0; + try { + await mkdir(path.dirname(auth), { recursive: true, mode: 0o700 }); + await mkdir(config.workspacePath, { recursive: true, mode: 0o700 }); + await writeFile(executable, "#!/usr/bin/env node\nconst a=process.argv.slice(2); const s=v=>[{type:'assistant',parent_tool_use_id:null,session_id:'x',message:{role:'assistant',stop_reason:'end_turn',content:[{type:'text',text:v}]}},{type:'result',subtype:'success',is_error:false,result:v,stop_reason:'end_turn',session_id:'x'}].map(JSON.stringify).join('\\n'); if(a.includes('mcp')) process.stdout.write('ok'); else process.stdout.write(s('leased'));", { mode: 0o700 }); + await chmod(executable, 0o700); + process.env.PATH = `${root}${path.delimiter}${priorPath ?? ""}`; + process.env.NOOPOLIS_RUN_ID = "dispatcher-grok-realm-test"; + const broker: EngineBrokerTurnClient = { + async turn(agentId,wakeId,prompt,endpoint,signal) { turns += 1;assert.equal(agentId,config.id);assert.match(wakeId,/^(first|second)$/u);assert.match(prompt,/work/u);assert.match(endpoint,/^http:\/\/127\.0\.0\.1:\d+\/mcp$/u);assert.equal(signal?.aborted,false);return "brokered"; } + }; + const handle = await startOrganizationRuntimeEngine(config, "DAIMON_UNUSED_CONTROL", undefined, undefined, broker); + assert.equal((await handle.wake({ id: "first", kind: "manual", text: "work" })).text, "brokered"); + assert.equal((await handle.wake({ id: "second", kind: "manual", text: "work" })).text, "brokered"); + assert.equal(turns, 2); + await assert.rejects(access(auth)); + await handle.stop(); + } finally { + if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; + if (priorRun === undefined) delete process.env.NOOPOLIS_RUN_ID; else process.env.NOOPOLIS_RUN_ID = priorRun; + await rm(root, { recursive: true, force: true }); + } +}); + test("Daimon frames one escaped identity envelope for every production engine", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-dispatcher-identity-")); const priorPath = process.env.PATH; @@ -81,8 +138,9 @@ test("Daimon frames one escaped identity envelope for every production engine", const cliStub = [ "#!/usr/bin/env node", "const args = process.argv.slice(2);", + "const stream = (value) => [{ type: 'assistant', parent_tool_use_id: null, session_id: 'fake', message: { role: 'assistant', stop_reason: 'end_turn', content: [{ type: 'text', text: value }] } }, { type: 'result', subtype: 'success', is_error: false, result: value, stop_reason: 'end_turn', session_id: 'fake' }].map(JSON.stringify).join('\\n');", "if (args.includes('mcp')) process.stdout.write('ok');", - "else if (args.includes('--single')) process.stdout.write(args[args.indexOf('--single') + 1]);", + "else if (args.includes('--single')) { const text = args[args.indexOf('--single') + 1]; process.stdout.write(stream(text)); }", "else if (args.includes('--print')) process.stdout.write(args[args.indexOf('--print') + 1]);", "else { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); process.stdout.write(Buffer.concat(chunks).toString('utf8')); }" ].join("\n"); @@ -103,6 +161,8 @@ test("Daimon frames one escaped identity envelope for every production engine", const envelope = JSON.stringify({ id: config.id, name: identity.name, instructions: identity.instructions }); assert.equal(result.text.split(envelope).length - 1, 1); assert.match(result.text, //); + assert.match(result.text, /caller owns delivery to the source conversation/u); + assert.match(result.text, /Do not seek transport credentials or invoke a transport CLI/u); assert.match(result.text, /payload/); await handle.stop(); } @@ -127,6 +187,9 @@ async function seedAuth(root: string, kind: "codex" | "grok" | "agy"): Promise, - agyBusAddress?: string + agyBusAddress?: string, + grokBroker?: EngineBrokerTurnClient, + organizationAgents?: readonly OrganizationRuntimeAgentConfig[], + sharedProtectedPaths: readonly string[] = [] ): Promise { await paths?.verify(); const canonicalAgent = paths === undefined ? agent : { ...agent, workspacePath: paths.workspacePath, runtimeHomePath: paths.runtimeHomePath }; - const readiness = await prepareEngineReadiness(canonicalAgent, canonicalAgent.runtimeHomePath, agyBusAddress); - const adapter = adapterFor(canonicalAgent, controlTokenEnv, readiness.verify, readiness.executablePath, readiness.engineHomePath, paths?.verify, agyBusAddress); + const readiness = canonicalAgent.engine.kind === "grok" && grokBroker !== undefined + ? { ...(await prepareEngineExecutable(canonicalAgent.id, "grok")), engineHomePath: path.join(canonicalAgent.runtimeHomePath, engineHomeName("grok")) } + : await prepareEngineReadiness(canonicalAgent, canonicalAgent.runtimeHomePath, agyBusAddress); + const wakeContext: import("../pi/piAgentWakeSupport.js").PiWakeEnvironmentContextRef = {}; + const grokSandbox = canonicalAgent.engine.kind === "grok" && paths !== undefined && organizationAgents !== undefined + ? () => prepareAndVerifyGrokSandbox({ + command: readiness.executablePath, + cwd: canonicalAgent.workspacePath, + engineHomePath: readiness.engineHomePath, + protectedPaths: grokSandboxProtectedPaths(canonicalAgent.id, organizationAgents, sharedProtectedPaths), + runtimeHomePath: canonicalAgent.runtimeHomePath + }) + : undefined; + const adapter = adapterFor(canonicalAgent, controlTokenEnv, readiness.verify, readiness.executablePath, readiness.engineHomePath, paths?.verify, agyBusAddress, await createProductionAgentTools(canonicalAgent, wakeContext), wakeContext, grokSandbox,grokBroker); const handle = await adapter.startAgent({ id: canonicalAgent.id, name: canonicalAgent.name, @@ -30,7 +52,7 @@ export async function startOrganizationRuntimeEngine( workspacePath: canonicalAgent.workspacePath }); await paths?.verify(); - return { + const result: AgentHandle = { ...handle, async wake(event) { await paths?.verify(); @@ -40,16 +62,41 @@ export async function startOrganizationRuntimeEngine( async stop() { await handle.stop(); }, status: () => handle.status() }; + return result; } -function adapterFor(agent: OrganizationRuntimeAgentConfig, controlTokenEnv: string, verifyExecutable: () => Promise, executablePath: string, engineHomePath: string, verifyRuntimePaths?: () => Promise, agyBusAddress?: string): PiHarnessAdapter { +export function grokSandboxProtectedPaths( + currentAgentId: string, + organizationAgents: readonly OrganizationRuntimeAgentConfig[], + sharedProtectedPaths: readonly string[] = [] +): readonly string[] { + return [ + GROK_SUBSCRIPTION_REALM.bootstrapMountPath, + GROK_SUBSCRIPTION_REALM.durableMountPath, + ...(organizationAgents.some((peer) => peer.engine.kind === "agy") ? [ + AGY_SUBSCRIPTION_REALM.unlockMountPath, + AGY_SUBSCRIPTION_REALM.durableMountPath + ] : []), + ...sharedProtectedPaths, + ...organizationAgents.filter((peer) => peer.id !== currentAgentId) + .flatMap((peer) => [peer.runtimeHomePath, peer.workspacePath]) + ]; +} + +function adapterFor(agent: OrganizationRuntimeAgentConfig, controlTokenEnv: string, verifyExecutable: () => Promise, executablePath: string, engineHomePath: string, verifyRuntimePaths?: () => Promise, agyBusAddress?: string, productionTools: readonly import("@earendil-works/pi-coding-agent").ToolDefinition[] = [], wakeEnvironmentContext: import("../pi/piAgentWakeSupport.js").PiWakeEnvironmentContextRef = {}, verifyGrokSandbox?: () => Promise,grokBroker?:EngineBrokerTurnClient): PiHarnessAdapter { const engine = agent.engine.kind; const sessionFactory = createCliSessionFactory( engine === "agy" ? { engine, maxToolTurns: 1, timeoutMs: 180_000, toolAccess: "none", dbusSessionBusAddress: agyBusAddress, redactedEnvironmentNames: [controlTokenEnv], identityPrompt: identityEnvelope(agent), command: executablePath, engineHomePath, verifyExecutable, verifyRuntimePaths } - : { engine, maxToolTurns: 2, timeoutMs: 180_000, redactedEnvironmentNames: [controlTokenEnv], identityPrompt: identityEnvelope(agent), command: executablePath, engineHomePath, verifyExecutable, verifyRuntimePaths } + : { engine, redactedEnvironmentNames: [controlTokenEnv], identityPrompt: identityEnvelope(agent), command: executablePath, engineHomePath, verifyExecutable, verifyRuntimePaths, + ...(engine==="grok"&&grokBroker!==undefined?{}:{credentialSecretValues: () => readPortableEngineCredentialSecrets(agent.id, engine, engineHomePath)}), + ...(engine==="grok"&&grokBroker!==undefined?{grokBrokerTurn:(prompt:string,endpoint:string,signal:AbortSignal)=>grokBroker.turn(agent.id,wakeEnvironmentContext.current??"wake",prompt,endpoint,signal)}:{}), + ...(engine === "grok" && verifyGrokSandbox ? { + grokSandboxProfile: GROK_DAIMON_SANDBOX_PROFILE, + verifyGrokSandbox + } : {}) } ); - return cliHarness(agent, sessionFactory, [controlTokenEnv]); + return cliHarness(agent, sessionFactory, [controlTokenEnv], productionTools, wakeEnvironmentContext); } /** @@ -61,6 +108,8 @@ function identityEnvelope(agent: OrganizationRuntimeAgentConfig): string { "", JSON.stringify({ id: agent.id, name: agent.name, instructions: agent.instructions }), "", + "Put the intended outward reply in your terminal response; the caller owns delivery to the source conversation. " + + "Do not seek transport credentials or invoke a transport CLI unless the caller explicitly mounted an authenticated transport tool.", "The following is the current wake event." ].join("\n") + "\n"; } @@ -68,7 +117,7 @@ function identityEnvelope(agent: OrganizationRuntimeAgentConfig): string { function cliHarness( agent: OrganizationRuntimeAgentConfig, sessionFactory: ReturnType, - protectedEnvironmentNames: readonly string[] + protectedEnvironmentNames: readonly string[], productionTools: readonly import("@earendil-works/pi-coding-agent").ToolDefinition[], wakeEnvironmentContext: import("../pi/piAgentWakeSupport.js").PiWakeEnvironmentContextRef ): PiHarnessAdapter { return new PiHarnessAdapter({ authPath: path.join(agent.runtimeHomePath, "auth.json"), @@ -80,6 +129,8 @@ function cliHarness( provider: "daimon-cli" }, sessionFactory, - protectedEnvironmentNames + protectedEnvironmentNames, + productionTools, + wakeEnvironmentContext }); } diff --git a/src/runtime/engineReadiness.test.ts b/src/runtime/engineReadiness.test.ts index 86c33cb..010aedf 100644 --- a/src/runtime/engineReadiness.test.ts +++ b/src/runtime/engineReadiness.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; -import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { chmod, link, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { ENGINE_CREDENTIAL_MATERIAL } from "./contractManifest.js"; +import { ENGINE_CREDENTIAL_MATERIAL, GROK_SUBSCRIPTION_REALM } from "./contractManifest.js"; import { engineAuthFile, engineHomeName, prepareEngineReadiness } from "./engineReadiness.js"; import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; @@ -35,6 +35,61 @@ test("pins an executable and accepts only a private refreshable local auth artif } }); +test("accepts refreshable native Grok subscription auth across access expiry", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-engine-grok-ready-")); + const previousPath = process.env.PATH; + try { + const config = agent(root, "grok"); + const executable = path.join(root, "grok"); + const authPath = path.join(config.runtimeHomePath, ".grok", "auth.json"); + await mkdir(config.workspacePath, { recursive: true, mode: 0o700 }); + await mkdir(path.dirname(authPath), { recursive: true, mode: 0o700 }); + await writeFile(executable, "#!/usr/bin/env node\nif (process.argv.includes('--version')) process.stdout.write('test');", { mode: 0o700 }); + await chmod(executable, 0o700); + const credential = (expiresAt: string, access = "not-logged", refresh = "not-logged") => ({ + "https://auth.x.ai::account": { key: access, refresh_token: refresh, expires_at: expiresAt } + }); + await writeFile(authPath, JSON.stringify(credential("2099-01-01T00:00:00.000Z")), { mode: 0o600 }); + await chmod(authPath, 0o600); + process.env.PATH = `${root}${path.delimiter}${previousPath ?? ""}`; + await (await prepareEngineReadiness(config, config.runtimeHomePath)).verify(); + + await writeFile(authPath, JSON.stringify(credential("2020-01-01T00:00:00.000Z")), { mode: 0o600 }); + await chmod(authPath, 0o600); + await (await prepareEngineReadiness(config, config.runtimeHomePath)).verify(); + + for (const unsupported of [ + { "https://auth.x.ai::account": { key: "not-logged", refresh_token: "not-logged" } }, + credential("not-a-date"), + { "https://auth.x.ai::account": { refresh_token: "not-logged", expires_at: "2099-01-01T00:00:00.000Z" } }, + { "https://auth.x.ai::account": { key: "not-logged", expires_at: "2099-01-01T00:00:00.000Z" } }, + credential("2099-01-01T00:00:00.000Z", "", "not-logged"), + credential("2099-01-01T00:00:00.000Z", "not-logged", ""), + credential("2020-01-01T00:00:00.000Z", "not-logged", " "), + { unexpected: true } + ]) { + await writeFile(authPath, JSON.stringify(unsupported), { mode: 0o600 }); + await chmod(authPath, 0o600); + await assert.rejects(prepareEngineReadiness(config, config.runtimeHomePath), /subscription authentication is not ready/); + } + + await writeFile(authPath, "{", { mode: 0o600 }); + await chmod(authPath, 0o600); + await assert.rejects(prepareEngineReadiness(config, config.runtimeHomePath), /subscription authentication is not ready/); + + await writeFile(authPath, JSON.stringify(credential("2099-01-01T00:00:00.000Z")), { mode: 0o600 }); + await chmod(authPath, 0o644); + await assert.rejects(prepareEngineReadiness(config, config.runtimeHomePath), /subscription authentication is not ready/); + + await chmod(authPath, 0o600); + await link(authPath, path.join(root, "linked-auth")); + await assert.rejects(prepareEngineReadiness(config, config.runtimeHomePath), /subscription authentication is not ready/); + } finally { + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + await rm(root, { recursive: true, force: true }); + } +}); + test("fails closed for missing or unsafe credentials without reflecting their path or contents", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-engine-auth-")); const previousPath = process.env.PATH; @@ -90,7 +145,7 @@ test("verifies AGY through its noninteractive native secure-storage probe withou test("readiness derives every engine home, credential file, and required mode from the manifest", () => { const root = "/runtime-home"; - for (const kind of ["codex", "grok"] as const) { + for (const kind of ["codex"] as const) { const rule = ENGINE_CREDENTIAL_MATERIAL[kind]; const engineHome = path.join(root, engineHomeName(kind)); assert.equal(engineHome, path.join(root, path.dirname(rule.destinationRelativePath))); @@ -98,4 +153,6 @@ test("readiness derives every engine home, credential file, and required mode fr assert.equal(rule.directoryMode, 0o700); assert.equal(rule.fileMode, 0o600); } + assert.equal(engineHomeName("grok"), ".grok"); + assert.equal(engineAuthFile("grok", path.join(root, ".grok")), path.join(root, GROK_SUBSCRIPTION_REALM.agentCredentialRelativePath)); }); diff --git a/src/runtime/engineReadiness.ts b/src/runtime/engineReadiness.ts index 11de80e..235dd78 100644 --- a/src/runtime/engineReadiness.ts +++ b/src/runtime/engineReadiness.ts @@ -6,6 +6,7 @@ import { createHash } from "node:crypto"; import { cliChildEnvironment } from "../pi/cliEnvironment.js"; import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; +import { hasRefreshablePortableCredential, portableCredentialSecretValues } from "./portableCredentialAuth.js"; const MAX_AUTH_BYTES = 64 * 1024; const MAX_PROBE_BYTES = 8 * 1024; @@ -135,32 +136,67 @@ async function verifyEngineAuth( async function verifyPortableEngineAuth(agentId: string, engine: "codex" | "grok", engineHomePath: string): Promise { const authPath = engineAuthFile(engine, engineHomePath); let handle: Awaited> | undefined; + let buffer: Buffer | undefined; try { const home = await lstat(engineHomePath); if (!home.isDirectory() || home.isSymbolicLink() || home.uid !== process.getuid?.() || (home.mode & 0o777) !== 0o700) { throw new Error("unsafe engine home"); } const entry = await lstat(authPath); - if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 0o777) !== 0o600 || entry.size > MAX_AUTH_BYTES) { - throw new Error("unsafe auth artifact"); - } + assertPrivateAuthEntry(entry); handle = await open(authPath, constants.O_RDONLY | noFollow()); const opened = await handle.stat(); - if (!sameIdentity(identity(entry), identity(opened)) || opened.size > MAX_AUTH_BYTES) throw new Error("auth artifact changed"); - const buffer = Buffer.alloc(Number(opened.size)); - await handle.read(buffer, 0, buffer.length, 0); - const value: unknown = JSON.parse(buffer.toString("utf8")); - const tokens = tokenPair(value); - if (tokens.access === undefined || tokens.refresh === undefined) throw new Error("unsupported auth artifact"); - const expiry = expiryAt(value); - if (expiry !== undefined && expiry <= Date.now() && !tokens.refresh) throw new Error("expired auth artifact"); + assertPrivateAuthEntry(opened); + if (!sameIdentity(identity(entry), identity(opened))) throw new Error("auth artifact changed"); + buffer = await handle.readFile(); + const after = await handle.stat(); + assertPrivateAuthEntry(after); + if (!sameIdentity(identity(opened), identity(after))) throw new Error("auth artifact changed"); + if (!hasRefreshablePortableCredential(engine, buffer)) throw new Error("unsupported auth artifact"); } catch { throw unavailable(agentId, engine, "subscription authentication is not ready; provision the supported local credential and retry"); } finally { + buffer?.fill(0); + await handle?.close().catch(() => undefined); + } +} + +/** Securely rereads the currently staged provider credential for exact output redaction. */ +export async function readPortableEngineCredentialSecrets( + agentId: string, + engine: "codex" | "grok", + engineHomePath: string +): Promise { + const authPath = engineAuthFile(engine, engineHomePath); + let handle: Awaited> | undefined; + let buffer: Buffer | undefined; + try { + const home = await lstat(engineHomePath); + if (!home.isDirectory() || home.isSymbolicLink() || home.uid !== process.getuid?.() || (home.mode & 0o777) !== 0o700) throw new Error("unsafe"); + const before = await lstat(authPath); assertPrivateAuthEntry(before); + handle = await open(authPath, constants.O_RDONLY | noFollow()); + const opened = await handle.stat(); assertPrivateAuthEntry(opened); + if (!sameIdentity(identity(before), identity(opened))) throw new Error("changed"); + buffer = await handle.readFile(); + const after = await handle.stat(); assertPrivateAuthEntry(after); + if (!sameIdentity(identity(opened), identity(after)) || !hasRefreshablePortableCredential(engine, buffer)) throw new Error("changed"); + return portableCredentialSecretValues(engine, buffer); + } catch { + throw unavailable(agentId, engine, "subscription authentication is not ready"); + } finally { + buffer?.fill(0); await handle?.close().catch(() => undefined); } } +function assertPrivateAuthEntry(entry: Awaited>): void { + if (!entry.isFile() || entry.isSymbolicLink() || Number(entry.uid) !== process.getuid?.() + || Number(entry.nlink) !== 1 || (Number(entry.mode) & 0o777) !== 0o600 + || Number(entry.size) < 1 || Number(entry.size) > MAX_AUTH_BYTES) { + throw new Error("unsafe auth artifact"); + } +} + export async function verifyAgySubscriptionEnrollment( agentId: string, executablePath: string, @@ -188,33 +224,6 @@ export async function verifyAgySubscriptionEnrollment( }); }); } -function tokenPair(value: unknown): { access?: string; refresh?: string } { - const source = object(value); - const nested = object(source?.tokens) ?? source; - return { - access: firstString(nested, ["access_token", "accessToken", "token"]), - refresh: firstString(nested, ["refresh_token", "refreshToken"]) - }; -} - -function expiryAt(value: unknown): number | undefined { - const candidate = firstNumber(object(value), ["expires_at", "expiresAt", "expiry"]) - ?? firstNumber(object(object(value)?.tokens), ["expires_at", "expiresAt", "expiry"]); - if (candidate === undefined) return undefined; - return candidate < 10_000_000_000 ? candidate * 1_000 : candidate; -} - -function object(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; -} -function firstString(value: Record | undefined, names: readonly string[]): string | undefined { - for (const name of names) if (typeof value?.[name] === "string" && value[name]!.length > 0) return value[name] as string; - return undefined; -} -function firstNumber(value: Record | undefined, names: readonly string[]): number | undefined { - for (const name of names) if (typeof value?.[name] === "number" && Number.isFinite(value[name])) return value[name] as number; - return undefined; -} function noFollow(): number { return (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; } function identity(value: Awaited>): FileIdentity { const numeric = value as typeof value & { dev: number; ino: number; size: number; mtimeMs: number; mode: number }; diff --git a/src/runtime/organizationRuntimeReadiness.ts b/src/runtime/organizationRuntimeReadiness.ts new file mode 100644 index 0000000..39fc7de --- /dev/null +++ b/src/runtime/organizationRuntimeReadiness.ts @@ -0,0 +1,79 @@ +import { startAgySubscriptionRealm, type AgySubscriptionRealm } from "./agySubscriptionRealm.js"; +import { prepareEngineExecutable, verifyAgySubscriptionEnrollment } from "./engineReadiness.js"; +import type { OrganizationRuntimeConfig } from "./organizationRuntime.js"; +import { prepareOrganizationRuntimePaths, type OrganizationRuntimePathAuthority } from "./physicalReadiness.js"; +import { materializePortableCredential } from "./portableCredentialMaterial.js"; +import { EngineBrokerControlClient } from "./engineBrokerControlClient.js"; + +export type OrganizationRuntimeHostReadiness = Readonly<{ + agyRealm?: AgySubscriptionRealm; + grokBroker?: EngineBrokerControlClient; + paths: OrganizationRuntimePathAuthority; + close(): Promise; +}>; + +export async function prepareProductionReadiness( + config: OrganizationRuntimeConfig +): Promise { + const paths = await prepareOrganizationRuntimePaths(config.agents); + let realm: AgySubscriptionRealm | undefined; + let grokBroker: EngineBrokerControlClient | undefined; + try { + for (const agent of config.agents) { + if (agent.engine.kind !== "codex") continue; + const canonical = paths.forAgent(agent); + await canonical.verify(); + await materializePortableCredential(agent, canonical.runtimeHomePath); + await canonical.verify(); + } + const grokAgents = config.agents.filter((agent): agent is typeof agent & { engine: { kind: "grok" } } => agent.engine.kind === "grok"); + if (grokAgents.length > 0) { + grokBroker = new EngineBrokerControlClient(); + await grokBroker.ready(); + } + const agy = config.agents.find((agent) => agent.engine.kind === "agy"); + if (agy !== undefined) { + realm = await startAgySubscriptionRealm(); + const canonical = paths.forAgent(agy); + await canonical.verify(); + const executable = await prepareEngineExecutable(agy.id, "agy"); + await verifyAgySubscriptionEnrollment( + agy.id, + executable.executablePath, + canonical.runtimeHomePath, + realm.busAddress + ); + await executable.verify(); + await canonical.verify(); + } + let closed = false; + return { + ...(realm === undefined ? {} : { agyRealm: realm }), + ...(grokBroker === undefined ? {} : { grokBroker }), + paths, + async close() { + if (closed) return; + closed = true; + const results = await Promise.allSettled( + [realm?.close(), paths.close()].filter((value): value is Promise => value !== undefined) + ); + const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + if (failures.length > 0) { + throw new AggregateError(failures, "organization runtime readiness cleanup failed"); + } + } + }; + } catch (error) { + const cleanup = await Promise.allSettled( + [realm?.close(), paths.close()].filter((value): value is Promise => value !== undefined) + ); + const failures = cleanup.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + if (failures.length > 0) { + throw new AggregateError( + [error, ...failures], + "organization runtime readiness failed and cleanup was incomplete" + ); + } + throw error; + } +} diff --git a/src/runtime/productionAgentTools.test.ts b/src/runtime/productionAgentTools.test.ts new file mode 100644 index 0000000..b7b4058 --- /dev/null +++ b/src/runtime/productionAgentTools.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createProductionAgentTools } from "./productionAgentTools.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +async function receipts(root: string): Promise { + return await Promise.all((await readdir(path.join(root, "tool-state"))).filter((name) => name.endsWith(".json")).map((name) => readFile(path.join(root, "tool-state", name), "utf8"))); +} + +test("production cognition mounts only declared MCP tools and records a bounded receipt", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-production-tools-")); + try { + const agent: OrganizationRuntimeAgentConfig = { id: "alpha", name: "Alpha", instructions: "work", workspacePath: root, runtimeHomePath: root, engine: { kind: "codex" }, mcp: [{ name: "lifecycle", transport: "stdio", command: process.execPath, args: [path.resolve("src/runtime/fixtures/testMcpServer.mjs")], env: {}, tools: ["checkpoint"] }] }; + const tools = await createProductionAgentTools(agent, { current: "schedule:release" }); assert.deepEqual(tools.map((tool) => tool.name), ["mcp_lifecycle_checkpoint"]); + const result = await tools[0]!.execute("call", { phase: "release" } as never, undefined, undefined, {} as never); + assert.ok(JSON.stringify(result).includes(`release:home=${root}`)); assert.equal(await readFile(path.join(root, "mcp-home-writable"), "utf8"), "ok"); assert.match((await receipts(root)).join(""), /"kind":"mcp".*"agent_id":"alpha".*"engine":"codex".*"tool":"checkpoint"/u); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("production Moltnet tool enforces compiled scope and records accepted message receipt", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-production-moltnet-")); + try { + const cli = path.join(root, "moltnet"); await writeFile(cli, "#!/usr/bin/env node\nlet s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{const q=JSON.parse(s);process.stdout.write(JSON.stringify({version:'moltnet.machine.v1',correlation_id:q.correlation_id,operation:'send_nudge',send_nudge:{accepted:true,message_id:'msg-1'}})+'\\n')})\n"); await chmod(cli, 0o755); + const agent: OrganizationRuntimeAgentConfig = { id: "alpha", name: "Alpha", instructions: "work", workspacePath: root, runtimeHomePath: root, engine: { kind: "codex" }, moltnet: { cliPath: cli, configPath: path.join(root, "config.json"), networks: [{ id: "news", rooms: ["desk"], dms: false }] } }; + const tool = (await createProductionAgentTools(agent, { current: "schedule:occurrence" }))[0]!; + await assert.rejects(tool.execute("call", { network: "news", target: "dm:beta", text: "no" } as never, undefined, undefined, {} as never), /not declared/u); + await tool.execute("call", { network: "news", target: "room:desk", text: "é".repeat(1024) } as never, undefined, undefined, {} as never); + await assert.rejects(tool.execute("call", { network: "news", target: "room:desk", text: `${"é".repeat(1024)}a` } as never, undefined, undefined, {} as never), /exceeds declared scope/u); + await tool.execute("call", { network: "news", target: "room:desk", text: "hello" } as never, undefined, undefined, {} as never); + await tool.execute("call", { network: "news", target: "room:desk", text: "hello" } as never, undefined, undefined, {} as never); + const replacement = (await createProductionAgentTools(agent, { current: "schedule:occurrence" }))[0]!; + await replacement.execute("call", { network: "news", target: "room:desk", text: "hello" } as never, undefined, undefined, {} as never); + const stored = await receipts(root); assert.match(stored.join(""), /"kind":"moltnet".*"delivery_id":"daimon:.*"message_id":"msg-1"/u); assert.equal(stored.length, 2); + await writeFile(path.join(root, "tool-state", "unrelated-torn.json"), "{"); + await replacement.execute("call", { network: "news", target: "room:desk", text: "hello" } as never, undefined, undefined, {} as never); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/runtime/productionAgentTools.ts b/src/runtime/productionAgentTools.ts new file mode 100644 index 0000000..69f20b4 --- /dev/null +++ b/src/runtime/productionAgentTools.ts @@ -0,0 +1,93 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, readdir, rename, unlink } from "node:fs/promises"; +import path from "node:path"; + +import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import type { OrganizationRuntimeAgentConfig, OrganizationRuntimeMcpServer } from "./organizationRuntime.js"; +import { cliChildEnvironment } from "../pi/cliEnvironment.js"; +import type { PiWakeEnvironmentContextRef } from "../pi/piAgentWakeSupport.js"; + +const MAX_RESULT = 65_536; const TIMEOUT = 10_000; + +export async function createProductionAgentTools(agent: OrganizationRuntimeAgentConfig, wakeContext: PiWakeEnvironmentContextRef = {}): Promise { + await mkdir(path.join(agent.runtimeHomePath, "tool-state"), { recursive: true, mode: 0o700 }); + const tools = [...await Promise.all((agent.mcp ?? []).map((server) => mcpTools(agent, server, wakeContext)))].flat(); + if (agent.moltnet !== undefined) tools.push(moltnetTool(agent, wakeContext)); + if (new Set(tools.map((tool) => tool.name)).size !== tools.length) throw new Error("compiled cognition tool names collide"); + return tools; +} + +async function mcpTools(agent: OrganizationRuntimeAgentConfig, server: OrganizationRuntimeMcpServer, wakeContext: PiWakeEnvironmentContextRef): Promise { + const { client, close } = await connect(agent, server); const listed = await client.listTools(undefined, { timeout: TIMEOUT }); + if (Buffer.byteLength(JSON.stringify(listed)) > MAX_RESULT) { await close(); throw new Error(`MCP server ${server.name} tool list exceeds bound`); } + const available = new Map(listed.tools.map((tool) => [tool.name, tool])); + const undeclared = [...available.keys()].filter((name) => !server.tools.includes(name)); + if (undeclared.length > 0) { await close(); throw new Error(`MCP server ${server.name} exposed undeclared tools`); } + const missing = server.tools.filter((name) => !available.has(name)); if (missing.length > 0) { await close(); throw new Error(`MCP server ${server.name} omitted declared tools`); } + await close(); + return server.tools.map((name) => { + const declared = available.get(name)!; + return { + name: `mcp_${safe(server.name)}_${safe(name)}`, + label: `${server.name}: ${name}`, + description: declared.description ?? `Call declared MCP tool ${server.name}/${name}`, + parameters: declared.inputSchema as ToolDefinition["parameters"], + async execute(_id, params) { + if (!wakeContext.current) throw new Error("MCP call requires an active wake"); + const actionId = `daimon:${createHash("sha256").update(JSON.stringify([wakeContext.current, agent.id, server.name, name, params])).digest("hex")}`; + const prior = await priorReceipt(agent, actionId); if (prior !== undefined) return { content: [{ type: "text", text: JSON.stringify(prior) }], details: prior }; + const active = await connect(agent, server); + try { + const result = await active.client.callTool({ name, arguments: params as Record }, undefined, { timeout: TIMEOUT }); + const bytes = JSON.stringify(result); if (Buffer.byteLength(bytes) > MAX_RESULT) throw new Error("MCP tool result exceeds bound"); + await receipt(agent, { kind: "mcp", agent_id: agent.id, engine: agent.engine.kind, delivery_id: actionId, server: server.name, tool: name, digest: digest(bytes), is_error: result.isError === true }); + return { content: "content" in result ? result.content as never : [{ type: "text", text: bytes }], details: { server: server.name, tool: name, is_error: result.isError === true } }; + } finally { await active.close(); } + } + } as ToolDefinition; + }); +} + +function moltnetTool(agent: OrganizationRuntimeAgentConfig, wakeContext: PiWakeEnvironmentContextRef): ToolDefinition { + return { + name: "moltnet_send", label: "Send a scoped Moltnet message", description: "Send to one room or DM declared for this agent.", + parameters: { type: "object", additionalProperties: false, required: ["network", "target", "text"], properties: { network: { type: "string" }, target: { type: "string" }, text: { type: "string", minLength: 1, maxLength: 2048 } } }, + async execute(_id, params) { + const input = params as { network: string; target: string; text: string }; const network = agent.moltnet!.networks.find((entry) => entry.id === input.network); + if (network === undefined || Buffer.byteLength(input.text) > 2_048) throw new Error("Moltnet action exceeds declared scope"); + const [kind, target] = input.target.split(":", 2); if ((kind === "room" && !network.rooms.includes(target ?? "")) || (kind === "dm" && !network.dms) || !target || !["room", "dm"].includes(kind ?? "")) throw new Error("Moltnet target is not declared"); + if (!wakeContext.current) throw new Error("Moltnet send requires an active wake"); + const deliveryId = `daimon:${createHash("sha256").update(JSON.stringify([wakeContext.current, agent.id, input.network, input.target, input.text])).digest("hex")}`; + const prior = await priorReceipt(agent, deliveryId); if (prior !== undefined) return { content: [{ type: "text", text: JSON.stringify(prior) }], details: prior }; + const response = await machine(agent.moltnet!.cliPath, agent.moltnet!.configPath, input.network, { version: "moltnet.machine.v1", correlation_id: deliveryId, operation: "send_nudge", send_nudge: { delivery_id: deliveryId, target: { kind, id: target }, body: input.text } }); + const result = response.send_nudge as { accepted?: boolean; message_id?: string } | undefined; if (result?.accepted !== true || typeof result.message_id !== "string") throw new Error("Moltnet send was not accepted"); + await receipt(agent, { kind: "moltnet", agent_id: agent.id, engine: agent.engine.kind, delivery_id: deliveryId, network: input.network, target: input.target, message_id: result.message_id }); + return { content: [{ type: "text", text: JSON.stringify({ accepted: true, message_id: result.message_id }) }], details: { accepted: true } }; + } + } as ToolDefinition; +} + +async function connect(agent: OrganizationRuntimeAgentConfig, server: OrganizationRuntimeMcpServer): Promise<{ client: Client; close(): Promise }> { + const client = new Client({ name: "daimon-production", version: "0.2.0" }); const headers: Record = server.authSecretEnv === undefined ? {} : { authorization: `Bearer ${requiredSecret(server.authSecretEnv)}` }; + const transport = server.transport === "stdio" + ? new StdioClientTransport({ command: server.command!, args: [...server.args], env: stringEnvironment({ ...server.env, ...cliChildEnvironment([], agent.runtimeHomePath, { executablePath: server.command }) }) }) + : server.transport === "sse" ? new SSEClientTransport(new URL(server.url!), { requestInit: { headers } }) + : new StreamableHTTPClientTransport(new URL(server.url!), { requestInit: { headers } }); + await client.connect(transport); return { client, close: () => client.close() }; +} +function requiredSecret(name: string): string { const value = process.env[name]; if (!value) throw new Error(`required MCP secret ${name} is missing`); return value; } +function receiptPath(agent: OrganizationRuntimeAgentConfig, deliveryId: string): string { return path.join(agent.runtimeHomePath, "tool-state", `${digest(deliveryId).slice(7)}.json`); } +async function receipt(agent: OrganizationRuntimeAgentConfig, value: Record): Promise { const deliveryId = String(value.delivery_id); const file = receiptPath(agent, deliveryId); const temporary = `${file}.${process.pid}.${Date.now()}.tmp`; const bytes = `${JSON.stringify({ ...value, at: new Date().toISOString() })}\n`; if (Buffer.byteLength(bytes) > MAX_RESULT) throw new Error("tool receipt exceeds bound"); const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } try { await rename(temporary, file); await pruneReceipts(path.dirname(file)); const directory = await open(path.dirname(file), constants.O_RDONLY); try { await directory.sync(); } finally { await directory.close(); } } catch (error) { await unlink(temporary).catch(() => undefined); throw error; } } +async function pruneReceipts(directory: string): Promise { const candidates = await Promise.all((await readdir(directory)).filter((name) => /^[a-f0-9]{64}\.json$/u.test(name)).map(async (name) => ({ name, info: await lstat(path.join(directory, name)) }))); if (candidates.some(({ info }) => !info.isFile() || info.nlink !== 1)) throw new Error("tool receipt directory contains an unsafe entry"); for (const candidate of candidates.sort((left, right) => right.info.mtimeMs - left.info.mtimeMs || right.name.localeCompare(left.name)).slice(2048)) await unlink(path.join(directory, candidate.name)); } +async function priorReceipt(agent: OrganizationRuntimeAgentConfig, deliveryId: string): Promise | undefined> { const file = receiptPath(agent, deliveryId); let handle; try { handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } try { const entry = await handle.stat(); if (!entry.isFile() || entry.size > MAX_RESULT) throw new Error("tool receipt is unsafe or exceeds bound"); const value = JSON.parse(await handle.readFile("utf8")) as Record; if (value.delivery_id !== deliveryId || value.agent_id !== agent.id || value.engine !== agent.engine.kind) throw new Error("tool receipt identity mismatch"); return value; } finally { await handle.close(); } } +async function machine(cli: string, config: string, network: string, request: unknown): Promise> { return await new Promise((resolve, reject) => { const child = spawn(cli, ["machine", "--config", config, "--network", network], { stdio: ["pipe", "pipe", "pipe"] }); let output = "", error = ""; const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("Moltnet machine timed out")); }, TIMEOUT); child.stdout.on("data", (chunk) => { output += chunk; if (Buffer.byteLength(output) > MAX_RESULT) child.kill("SIGKILL"); }); child.stderr.on("data", (chunk) => { error += chunk; }); child.once("error", reject); child.once("exit", (code) => { clearTimeout(timer); if (code !== 0) reject(new Error(`Moltnet machine failed: ${error.slice(0, 1024)}`)); else { try { resolve(JSON.parse(output.trim().split("\n")[0]!) as Record); } catch (cause) { reject(cause); } } }); child.stdin.end(`${JSON.stringify(request)}\n`); }); } +function digest(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; } +function safe(value: string): string { return value.replace(/[^A-Za-z0-9_]/gu, "_").slice(0, 48); } +function stringEnvironment(value: NodeJS.ProcessEnv): Record { return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => entry[1] !== undefined)); } From 96b321a26ce4039402201a82a3ba144e45e85efe Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:51 +0200 Subject: [PATCH 07/24] feat(runtime): run durable organization runtime v2 schedules --- src/runtime/cli.test.ts | 22 +- src/runtime/cli.ts | 19 +- src/runtime/organizationRuntime.test.ts | 28 +- src/runtime/organizationRuntime.ts | 70 +++-- src/runtime/organizationRuntimeControl.ts | 144 +++++++--- src/runtime/organizationRuntimeHost.test.ts | 19 ++ src/runtime/organizationRuntimeHost.ts | 62 +---- src/runtime/organizationRuntimeParsing.ts | 106 +++++++- src/runtime/schedule.test.ts | 207 ++++++++++++++ src/runtime/schedule.ts | 287 ++++++++++++++++++++ src/runtime/wakeAcceptance.test.ts | 187 ++++++++++++- src/runtime/wakeAcceptanceRecord.ts | 47 ++++ src/runtime/wakeAcceptanceRetention.ts | 19 ++ src/runtime/wakeAcceptanceStore.ts | 140 ++++++---- src/runtime/wakeAcceptanceTypes.ts | 27 +- 15 files changed, 1183 insertions(+), 201 deletions(-) create mode 100644 src/runtime/schedule.test.ts create mode 100644 src/runtime/schedule.ts create mode 100644 src/runtime/wakeAcceptanceRecord.ts create mode 100644 src/runtime/wakeAcceptanceRetention.ts diff --git a/src/runtime/cli.test.ts b/src/runtime/cli.test.ts index f5ea766..8f54a5e 100644 --- a/src/runtime/cli.test.ts +++ b/src/runtime/cli.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; @@ -41,9 +41,12 @@ test("CLI strictly authenticates and routes a production Daimon engine", async ( await mkdir(workspace, { recursive: true, mode: 0o700 }); await mkdir(runtimeHome, { recursive: true, mode: 0o700 }); await mkdir(acceptanceStore, { recursive: true, mode: 0o700 }); - await mkdir(path.join(runtimeHome, ".codex"), { recursive: true, mode: 0o700 }); - await writeFile(path.join(runtimeHome, ".codex", "auth.json"), JSON.stringify({ tokens: { access_token: "test-access", refresh_token: "test-refresh" } }), { mode: 0o600 }); - await chmod(path.join(runtimeHome, ".codex", "auth.json"), 0o600); + await mkdir(path.join(runtimeHome, ".daimon-inbound"), { recursive: true, mode: 0o700 }); + const inboundAuth = path.join(runtimeHome, ".daimon-inbound", "codex-auth"); + const runtimeAuth = path.join(runtimeHome, ".codex", "auth.json"); + const readinessReceipt = path.join(root, "state", "runtime-readiness.json"); + await writeFile(inboundAuth, JSON.stringify({ tokens: { access_token: "test-access", refresh_token: "test-refresh" } }), { mode: 0o600 }); + await chmod(inboundAuth, 0o600); await writeFile(program, `#!/usr/bin/env node\nif (process.argv.includes('--version')) process.stdout.write('test'); else { process.stdin.resume(); process.stdin.on('end', () => process.stdout.write(process.env.${tokenEnv} ?? 'absent')); }`); await chmod(program, 0o700); await writeFile(configPath, JSON.stringify({ @@ -55,13 +58,17 @@ test("CLI strictly authenticates and routes a production Daimon engine", async ( }] })); const child = spawn(process.execPath, ["--import", "tsx", "src/runtime/cli.ts", "run", "--config", configPath], { - cwd: process.cwd(), env: { ...process.env, PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`, [tokenEnv]: token, ...(supportsC0 ? { DAIMON_RUNTIME_ACCEPTANCE_STORE: acceptanceStore } : {}), NOOPOLIS_RUN_ID: "runtime-cli-test" }, stdio: ["ignore", "pipe", "pipe"] + cwd: process.cwd(), env: { ...process.env, PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`, [tokenEnv]: token, DAIMON_RUNTIME_READINESS_RECEIPT: readinessReceipt, ...(supportsC0 ? { DAIMON_RUNTIME_ACCEPTANCE_STORE: acceptanceStore } : {}), NOOPOLIS_RUN_ID: "runtime-cli-test" }, stdio: ["ignore", "pipe", "pipe"] }); const output: Buffer[] = []; child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)); child.stderr?.on("data", (chunk: Buffer) => output.push(chunk)); try { await waitForHealth(port, token, child, output); + assert.deepEqual(JSON.parse(await readFile(readinessReceipt, "utf8")), { version: "noopolis.daimon.readiness-receipt.v1", agents: [{ agent_id: "agent", engine: "codex" }] }); + assert.equal(await readFile(runtimeAuth, "utf8"), await readFile(inboundAuth, "utf8")); + assert.equal((await lstat(path.dirname(runtimeAuth))).mode & 0o777, 0o700); + assert.equal((await lstat(runtimeAuth)).mode & 0o777, 0o600); const readiness = await fetch(`http://127.0.0.1:${port}/healthz`); assert.equal(readiness.status, 200); assert.deepEqual(await readiness.json(), { status: "ok" }); @@ -97,6 +104,11 @@ test("CLI strictly authenticates and routes a production Daimon engine", async ( const acceptance = await accepted.json() as { acceptance_id: string; state: string; text?: string }; assert.equal(acceptance.state, "accepted"); assert.equal(acceptance.text, undefined); + const activityV2 = await fetch(`http://127.0.0.1:${port}/v2/activity`, { headers: { authorization: `Bearer ${token}` } }); + assert.equal(activityV2.status, 200); + const activityBody = await activityV2.json() as { version: string; items: Array<{ delivery_id: string }> }; + assert.equal(activityBody.version, "noopolis.daimon.organization-runtime-activity.v2"); + assert.equal(activityBody.items.some((item) => item.delivery_id === "delivery-1"), true); await waitForReceipt(port, token, acceptance.acceptance_id); const conflict = await fetch(`http://127.0.0.1:${port}/v2/wakes`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ agent_id: "agent", delivery_id: "delivery-1", event: { diff --git a/src/runtime/cli.ts b/src/runtime/cli.ts index 4c00adc..1711690 100644 --- a/src/runtime/cli.ts +++ b/src/runtime/cli.ts @@ -1,19 +1,23 @@ #!/usr/bin/env node -import { readFile, realpath, stat } from "node:fs/promises"; +import { mkdir, open, readFile, realpath, rename, stat } from "node:fs/promises"; import { createHash, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import { pathToFileURL } from "node:url"; +import path from "node:path"; import { createOrganizationRuntimeHost } from "./organizationRuntimeHost.js"; import { createOrganizationRuntimeControlHost } from "./organizationRuntimeControl.js"; import { runAgySubscriptionBootstrap } from "./agySubscriptionBootstrap.js"; import { parseOrganizationRuntimeConfig, parseOrganizationRuntimeWakeRequest } from "./organizationRuntime.js"; +import { runEngineBrokerServiceCli } from "./engineBrokerServiceCli.js"; const MAX_BODY_BYTES = 1_048_576; const MAX_CONFIG_BYTES = 1_048_576; const ACCEPTANCE_STORE_ENV = "DAIMON_RUNTIME_ACCEPTANCE_STORE"; +const READINESS_RECEIPT_ENV = "DAIMON_RUNTIME_READINESS_RECEIPT"; export async function runOrganizationRuntimeCli(arguments_: readonly string[], environment = process.env): Promise { + if(arguments_.length===2&&arguments_[0]==="engine-broker"&&arguments_[1]==="serve"){await runEngineBrokerServiceCli();return;} const command = parseArguments(arguments_); const config = parseOrganizationRuntimeConfig(JSON.parse(await readBoundedFile(command.configPath))); if (command.kind === "agy-login") { @@ -31,6 +35,7 @@ export async function runOrganizationRuntimeCli(arguments_: readonly string[], e void handleRequest(request, response, host, control, environment[config.host.controlTokenEnv]); }); try { + if (environment[READINESS_RECEIPT_ENV]) await writeReadinessReceipt(environment[READINESS_RECEIPT_ENV]!, config); await listen(server, config.host.bindHost, config.host.port); } catch (error) { await host.stop(); @@ -62,6 +67,13 @@ export async function runOrganizationRuntimeCli(arguments_: readonly string[], e process.once("SIGTERM", onSignal); } +async function writeReadinessReceipt(file: string, config: ReturnType): Promise { + const value = `${JSON.stringify({ version: "noopolis.daimon.readiness-receipt.v1", agents: config.agents.map((agent) => ({ agent_id: agent.id, engine: agent.engine.kind })).sort((left, right) => left.agent_id.localeCompare(right.agent_id)) })}\n`; + await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); const temporary = `${file}.${process.pid}.tmp`; + const handle = await open(temporary, "wx", 0o600); try { await handle.writeFile(value); await handle.sync(); } finally { await handle.close(); } + await rename(temporary, file); const directory = await open(path.dirname(file), "r"); try { await directory.sync(); } finally { await directory.close(); } +} + export function parseOrganizationRuntimeCliArguments(arguments_: readonly string[]): Readonly<{ configPath: string; kind: "agy-login" | "run"; @@ -114,6 +126,11 @@ async function handleRequest( const receipt = await control.wakeReceipt(expectedToken, acceptanceId); return receipt === undefined ? respond(response, 404, { error: "not_found" }) : respond(response, 200, receipt); } + if (control !== undefined && request.method === "GET" && url.pathname === "/v2/activity") { + assertQuery(url, []); + const activity = await control.activityV2(expectedToken); + return activity === undefined ? respond(response, 404, { error: "not_found" }) : respond(response, 200, activity); + } return respond(response, 404, { error: "not_found" }); } catch (error) { return respond(response, 400, { error: error instanceof Error ? error.message : "invalid_request" }); diff --git a/src/runtime/organizationRuntime.test.ts b/src/runtime/organizationRuntime.test.ts index 504a320..f486ecf 100644 --- a/src/runtime/organizationRuntime.test.ts +++ b/src/runtime/organizationRuntime.test.ts @@ -73,7 +73,7 @@ test("bounds programmatic config size, agents, and every string field", () => { test("rejects unknown versions and organization semantics before any side effect", () => { const unknownVersion = valid(); - unknownVersion.version = "noopolis.daimon.organization-runtime.v2" as typeof ORGANIZATION_RUNTIME_VERSION; + unknownVersion.version = "noopolis.daimon.organization-runtime.v3" as typeof ORGANIZATION_RUNTIME_VERSION; assert.throws(() => parseOrganizationRuntimeConfig(unknownVersion), /config.version/); for (const forbidden of ["teams", "roles", "parents", "members", "edges", "schedules", "wakePolicy", "deployment", "moltnet"]) { const config = valid() as Record; @@ -82,6 +82,32 @@ test("rejects unknown versions and organization semantics before any side effect } }); +test("v2 cron schedules reject out-of-range and unsupported syntax", () => { + for (const cron of ["60 0 * * *", "0 24 * * *", "0 0 0 * *", "0 0 32 * *", "0 0 * 0 *", "0 0 * 13 *", "0 0 * * 8", "5-1 * * * *", "*/0 * * * *", "L * * * *"]) { + const source = valid(); + const config = { ...source, version: "noopolis.daimon.organization-runtime.v2", agents: [{ ...source.agents[0], schedule: { kind: "cron", cron, timezone: "UTC", prompt: "work" } }] }; + assert.throws(() => parseOrganizationRuntimeConfig(config), /cron is invalid/); + } +}); + +test("v2 schedules reject impossible cron, oversized prompt, and out-of-range cadence", () => { + const source = valid(); + const scheduled = (schedule: unknown) => ({ ...source, version: "noopolis.daimon.organization-runtime.v2", agents: [{ ...source.agents[0], schedule }] }); + assert.throws(() => parseOrganizationRuntimeConfig(scheduled({ kind: "cron", cron: "0 0 31 2 *", timezone: "UTC", prompt: "work" })), /impossible/); + assert.throws(() => parseOrganizationRuntimeConfig(scheduled({ kind: "every", interval_ms: 31_536_000_001, prompt: "work" })), /outside its bound/); + assert.throws(() => parseOrganizationRuntimeConfig(scheduled({ kind: "every", interval_ms: 1, prompt: "x".repeat(4_097) })), /string limit/); + assert.equal(parseOrganizationRuntimeConfig(scheduled({ kind: "every", interval_ms: 31_536_000_000, prompt: "work" })).version, "noopolis.daimon.organization-runtime.v2"); +}); + +test("v2 cron schedules normalize whitespace and reject unbounded steps and text", () => { + const source = valid(); + const scheduled = (cron: string) => ({ ...source, version: "noopolis.daimon.organization-runtime.v2", agents: [{ ...source.agents[0], schedule: { kind: "cron", cron, timezone: "UTC", prompt: "work" } }] }); + const parsed = parseOrganizationRuntimeConfig(scheduled(" 0 5 * * * ")); + assert.equal(parsed.agents[0]?.schedule?.kind === "cron" ? parsed.agents[0].schedule.cron : undefined, "0 5 * * *"); + assert.throws(() => parseOrganizationRuntimeConfig(scheduled(`*/${"9".repeat(400)} * * * *`)), /invalid/); + assert.throws(() => parseOrganizationRuntimeConfig(scheduled("*".repeat(4_097))), /string limit/); +}); + test("rejects unsafe auth names, duplicate ids, and invalid absolute paths", () => { const unsafe = valid(); unsafe.host.controlTokenEnv = "TOKEN; rm"; diff --git a/src/runtime/organizationRuntime.ts b/src/runtime/organizationRuntime.ts index 494b251..c9247de 100644 --- a/src/runtime/organizationRuntime.ts +++ b/src/runtime/organizationRuntime.ts @@ -1,51 +1,49 @@ -/** The only configuration version accepted by the v1 runtime contract. */ -export const ORGANIZATION_RUNTIME_VERSION = "noopolis.daimon.organization-runtime.v1" as const; -export const ORGANIZATION_RUNTIME_MAX_AGENTS = 32; -export const ORGANIZATION_RUNTIME_MAX_CONFIG_BYTES = 1_048_576; -export const ORGANIZATION_RUNTIME_MAX_STRING_BYTES = 16_384; -export const ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS = 4_096; -export const ORGANIZATION_RUNTIME_MAX_WAKE_TEXT_BYTES = 16_384; +import { + ORGANIZATION_RUNTIME_CONFIG_SCHEMA, + ORGANIZATION_RUNTIME_CONFIG_V2_SCHEMA, + ORGANIZATION_RUNTIME_MAX_AGENTS, + ORGANIZATION_RUNTIME_MAX_CONFIG_BYTES, + ORGANIZATION_RUNTIME_MAX_STRING_BYTES, + ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, + ORGANIZATION_RUNTIME_MAX_WAKE_TEXT_BYTES, + ORGANIZATION_RUNTIME_MAX_SCHEDULE_INTERVAL_MS, + ORGANIZATION_RUNTIME_V2_VERSION, + ORGANIZATION_RUNTIME_VERSION +} from "../contracts/organizationRuntimeContract.js"; -/** Machine-readable companion schema for config producers and validators. */ -export const ORGANIZATION_RUNTIME_CONFIG_SCHEMA = { - $schema: "https://json-schema.org/draft/2020-12/schema", $id: ORGANIZATION_RUNTIME_VERSION, type: "object", additionalProperties: false, - required: ["version", "host", "agents"], - properties: { - version: { const: ORGANIZATION_RUNTIME_VERSION }, - host: { type: "object", additionalProperties: false, required: ["bindHost", "port", "controlTokenEnv"], properties: { - bindHost: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, - port: { type: "integer", minimum: 1, maximum: 65_535 }, - controlTokenEnv: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "^[A-Za-z_][A-Za-z0-9_]*$" } - } }, - agents: { type: "array", minItems: 1, maxItems: ORGANIZATION_RUNTIME_MAX_AGENTS, items: { - type: "object", additionalProperties: false, required: ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine"], properties: { - id: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, - name: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, - instructions: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, - workspacePath: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "^/" }, - runtimeHomePath: { type: "string", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "^/" }, - engine: { type: "object", additionalProperties: false, required: ["kind"], properties: { - kind: { enum: ["codex", "grok", "agy"] } - } } - } - } } - } -} as const; +export { + ORGANIZATION_RUNTIME_CONFIG_SCHEMA, + ORGANIZATION_RUNTIME_CONFIG_V2_SCHEMA, + ORGANIZATION_RUNTIME_MAX_AGENTS, + ORGANIZATION_RUNTIME_MAX_CONFIG_BYTES, + ORGANIZATION_RUNTIME_MAX_STRING_BYTES, + ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, + ORGANIZATION_RUNTIME_MAX_WAKE_TEXT_BYTES, + ORGANIZATION_RUNTIME_MAX_SCHEDULE_INTERVAL_MS, + ORGANIZATION_RUNTIME_V2_VERSION, + ORGANIZATION_RUNTIME_VERSION +}; export type OrganizationRuntimeEngineKind = "codex" | "grok" | "agy"; export type OrganizationRuntimeEngineIntent = Readonly<{ kind: OrganizationRuntimeEngineKind }>; -export type OrganizationRuntimeAgentConfig = Readonly<{ id: string; name: string; instructions: string; workspacePath: string; runtimeHomePath: string; engine: OrganizationRuntimeEngineIntent }>; +export type OrganizationRuntimeMcpServer = Readonly<{ name: string; transport: "stdio" | "sse" | "streamable_http"; command?: string; args: readonly string[]; env: Readonly>; authSecretEnv?: string; url?: string; tools: readonly string[] }>; +export type OrganizationRuntimeMoltnet = Readonly<{ cliPath: string; configPath: string; networks: readonly Readonly<{ id: string; rooms: readonly string[]; dms: boolean }>[] }>; +export type OrganizationRuntimeSchedule = + | Readonly<{ kind: "disabled" }> + | Readonly<{ kind: "every"; interval_ms: number; prompt: string }> + | Readonly<{ kind: "cron"; cron: string; timezone: string; prompt: string }>; +export type OrganizationRuntimeAgentConfig = Readonly<{ id: string; name: string; instructions: string; workspacePath: string; runtimeHomePath: string; engine: OrganizationRuntimeEngineIntent; schedule?: OrganizationRuntimeSchedule; mcp?: readonly OrganizationRuntimeMcpServer[]; moltnet?: OrganizationRuntimeMoltnet }>; export type OrganizationRuntimeHostConfig = Readonly<{ bindHost: string; port: number; /** Variable name only; never secret configuration data. */ controlTokenEnv: string }>; -export type OrganizationRuntimeConfig = Readonly<{ version: typeof ORGANIZATION_RUNTIME_VERSION; host: OrganizationRuntimeHostConfig; agents: readonly OrganizationRuntimeAgentConfig[] }>; +export type OrganizationRuntimeConfig = Readonly<{ version: typeof ORGANIZATION_RUNTIME_VERSION | typeof ORGANIZATION_RUNTIME_V2_VERSION; host: OrganizationRuntimeHostConfig; agents: readonly OrganizationRuntimeAgentConfig[] }>; export type OrganizationRuntimeLifecycleState = "starting" | "running" | "stopping" | "stopped"; -export type OrganizationRuntimeWakeEvent = Readonly<{ version: "noopolis.daimon.wake.v1"; id: string; kind: "manual" | "message" | "external"; text: string; occurredAt: string }>; +export type OrganizationRuntimeWakeEvent = Readonly<{ version: "noopolis.daimon.wake.v1"; id: string; kind: "manual" | "message" | "schedule" | "external"; text: string; occurredAt: string }>; export type OrganizationRuntimeWakeRequest = Readonly<{ token: string | undefined; agentId: string; event: OrganizationRuntimeWakeEvent }>; export type OrganizationRuntimeWakeResult = | Readonly<{ version: "noopolis.daimon.wake-result.v1"; status: "completed"; agentId: string; wakeId: string; text: string; durationMs: number }> | Readonly<{ version: "noopolis.daimon.wake-result.v1"; status: "rejected"; agentId: string; wakeId: string; code: "invalid_request" | "unauthorized" | "unknown_agent" | "queue_full" }> | Readonly<{ version: "noopolis.daimon.wake-result.v1"; status: "stopped"; agentId: string; wakeId: string; code: "host_stopping" | "host_stopped" | "queued_wake_stopped" | "active_wake_aborted" }> | Readonly<{ version: "noopolis.daimon.wake-result.v1"; status: "failed"; agentId: string; wakeId: string; code: "engine_failed" }>; -export type OrganizationRuntimeAgentHealth = Readonly<{ agentId: string; state: OrganizationRuntimeLifecycleState | "idle" | "running" | "failed" }>; +export type OrganizationRuntimeAgentHealth = Readonly<{ agentId: string; engine: OrganizationRuntimeEngineIntent["kind"]; state: OrganizationRuntimeLifecycleState | "idle" | "running" | "failed" }>; export type OrganizationRuntimeHealth = Readonly<{ version: "noopolis.daimon.organization-runtime-health.v1"; state: OrganizationRuntimeLifecycleState; agents: readonly OrganizationRuntimeAgentHealth[] }>; export type OrganizationRuntimeActivityRequest = Readonly<{ agentId?: string; cursor?: string; limit: number }>; export type OrganizationRuntimeActivity = Readonly<{ id: string; agentId: string; wakeId?: string; kind: "wake_started" | "wake_completed" | "wake_rejected" | "wake_aborted" | "agent_stopped"; occurredAt: string }>; diff --git a/src/runtime/organizationRuntimeControl.ts b/src/runtime/organizationRuntimeControl.ts index 3fc073f..bd5cf08 100644 --- a/src/runtime/organizationRuntimeControl.ts +++ b/src/runtime/organizationRuntimeControl.ts @@ -8,9 +8,12 @@ import { type OrganizationRuntimeWakeRequest } from "./organizationRuntime.js"; import { createOrganizationRuntimeHost } from "./organizationRuntimeHost.js"; +import { createScheduleController, type ScheduleController, type ScheduleControllerOptions } from "./schedule.js"; import { WakeAcceptanceConflictError, WakeAcceptanceStore, WakeExecutionClaimLostError, publicAcceptance, type WakeAcceptanceStoreTestOptions } from "./wakeAcceptanceStore.js"; import { parseWakeAcceptanceRequest, + ACTIVITY_V2_VERSION, + type OrganizationRuntimeActivityV2, type OrganizationRuntimeWakeAcceptanceRequest, type OrganizationRuntimeWakeAcceptanceResult, type OrganizationRuntimeWakeReceiptStatus @@ -19,9 +22,13 @@ import { export type OrganizationRuntimeControlHost = OrganizationRuntimeHost & Readonly<{ accept(request: unknown): Promise; wakeReceipt(token: string | undefined, acceptanceId: string): Promise; + activityV2(token: string | undefined): Promise; }>; export type OrganizationRuntimeControlOptions = Readonly<{ acceptanceStorePath: string; controlToken?: string }>; -type TestControlOptions = OrganizationRuntimeControlOptions & Readonly<{ storeOptions?: WakeAcceptanceStoreTestOptions }>; +type TestControlOptions = OrganizationRuntimeControlOptions & Readonly<{ + scheduleOptions?: Pick; + storeOptions?: WakeAcceptanceStoreTestOptions; +}>; type CoreHost = OrganizationRuntimeHost; type AcceptanceRecord = Awaited>["record"]; @@ -31,7 +38,9 @@ type AcceptanceRecord = Awaited>["reco */ export function createOrganizationRuntimeControlHost(config: unknown, options: OrganizationRuntimeControlOptions): OrganizationRuntimeControlHost { const parsed = parseOrganizationRuntimeConfig(config); - return createControl(parsed, createOrganizationRuntimeHost(parsed), options); + return createControl(parsed, createOrganizationRuntimeHost(parsed, { + sharedProtectedPaths: [options.acceptanceStorePath] + }), options); } /** @internal Test seam; intentionally absent from the public runtime barrel. */ @@ -39,41 +48,101 @@ export function createOrganizationRuntimeControlHostWithCoreForTest(config: unkn return createControl(parseOrganizationRuntimeConfig(config), host, options, options.storeOptions); } -function createControl(config: OrganizationRuntimeConfig, host: CoreHost, options: OrganizationRuntimeControlOptions, storeOptions?: WakeAcceptanceStoreTestOptions): OrganizationRuntimeControlHost { +function createControl(config: OrganizationRuntimeConfig, host: CoreHost, options: TestControlOptions, storeOptions?: WakeAcceptanceStoreTestOptions): OrganizationRuntimeControlHost { const expectedToken = options.controlToken ?? process.env[config.host.controlTokenEnv]; const knownAgents = new Set(config.agents.map((agent) => agent.id)); const ownerId = randomUUID(); const inFlight = new Map>(); + const agentTails = new Map>(); + const acceptanceTails = new Map>(); + const retryWaiters = new Set<() => void>(); let store: WakeAcceptanceStore | undefined; + let schedules: ScheduleController | undefined; let started = false; let stopping = false; + const waitUntil = async (timestamp: string): Promise => { + if (stopping) return; + await new Promise((resolve) => { + let settled = false; + let timer: ReturnType; + const finish = (): void => { if (settled) return; settled = true; clearTimeout(timer); retryWaiters.delete(finish); resolve(); }; + timer = setTimeout(finish, Math.max(1, Date.parse(timestamp) - Date.now())); + retryWaiters.add(finish); + }); + }; + + const serializeAcceptance = async (agentId: string, operation: () => Promise): Promise => { + const prior = acceptanceTails.get(agentId) ?? Promise.resolve(); + const result = prior.catch(() => undefined).then(operation); + const tail = result.then(() => undefined, () => undefined); + acceptanceTails.set(agentId, tail); + try { return await result; } finally { if (acceptanceTails.get(agentId) === tail) acceptanceTails.delete(agentId); } + }; + const dispatch = (record: AcceptanceRecord): void => { if (inFlight.has(record.acceptance_id) || store === undefined) return; const activeStore = store; - const work = (async () => { - const claim = await activeStore.acquireClaim(record.acceptance_id, ownerId); - if (claim.state !== "acquired") return; - const current = await activeStore.transitionClaimed(record.acceptance_id, claim.claim, "running"); - if (current.state !== "running") return; - const request: OrganizationRuntimeWakeRequest = { - token: expectedToken, - agentId: current.agent_id, - event: { version: "noopolis.daimon.wake.v1", id: current.delivery_id, kind: current.event.kind, text: current.event.text, occurredAt: current.event.occurred_at } - }; - const result = await host.wake(request); - if (result.status === "completed") await activeStore.transitionClaimed(current.acceptance_id, claim.claim, "completed"); - else if (result.status === "failed") await activeStore.transitionClaimed(current.acceptance_id, claim.claim, "failed", "engine_failed"); - else if (result.status === "stopped") await activeStore.transitionClaimed(current.acceptance_id, claim.claim, "stopped", result.code === "host_stopped" ? "host_stopped" : "host_stopping"); - else await activeStore.transitionClaimed(current.acceptance_id, claim.claim, "failed", result.code === "queue_full" ? "queue_full" : result.code === "unknown_agent" ? "unknown_agent" : "host_stopped"); - })().catch(async (error: unknown) => { - if (error instanceof WakeExecutionClaimLostError) return; - const claim = await activeStore.acquireClaim(record.acceptance_id, ownerId).catch(() => undefined); - if (claim?.state === "acquired") await activeStore.transitionClaimed(record.acceptance_id, claim.claim, "failed", "engine_failed").catch(() => undefined); - }).finally(() => { inFlight.delete(record.acceptance_id); }); + const execute = async (): Promise => { + while (!stopping) { + const acquired = await activeStore.acquireClaim(record.acceptance_id, ownerId); + if (acquired.state === "terminal") return; + if (acquired.state === "held") { await waitUntil(acquired.retry_at); continue; } + let activeClaim = acquired.claim; + try { + const current = await activeStore.transitionClaimed(record.acceptance_id, activeClaim, "running"); + if (current.state !== "running") return; + let renewal: Promise = Promise.resolve(); + const heartbeat = setInterval(() => { + renewal = renewal.then(async () => { + activeClaim = await activeStore.renewClaim(current.acceptance_id, activeClaim); + await activeStore.transitionClaimed(current.acceptance_id, activeClaim, "running"); + }); + }, activeStore.claimHeartbeatIntervalMs()); + const request: OrganizationRuntimeWakeRequest = { + token: expectedToken, agentId: current.agent_id, + event: { version: "noopolis.daimon.wake.v1", id: current.delivery_id, kind: current.event.kind, text: current.event.text, occurredAt: current.event.occurred_at } + }; + let result; + try { result = await host.wake(request); } catch { + clearInterval(heartbeat); await renewal; + await activeStore.transitionClaimed(current.acceptance_id, activeClaim, "failed", "engine_failed"); return; + } + clearInterval(heartbeat); await renewal; + // A crash here retries at least once with the same delivery/wake id. + if (result.status === "completed") await activeStore.transitionClaimed(current.acceptance_id, activeClaim, "completed", undefined, result.text); + else if (result.status === "failed") await activeStore.transitionClaimed(current.acceptance_id, activeClaim, "failed", "engine_failed"); + else if (result.status === "stopped") await activeStore.transitionClaimed(current.acceptance_id, activeClaim, "stopped", result.code === "host_stopped" ? "host_stopped" : "host_stopping"); + else await activeStore.transitionClaimed(current.acceptance_id, activeClaim, "failed", result.code === "queue_full" ? "queue_full" : result.code === "unknown_agent" ? "unknown_agent" : "host_stopped"); + return; + } catch (error) { + if (!(error instanceof WakeExecutionClaimLostError)) await waitUntil(activeClaim.expires_at); + } + } + }; + const prior = agentTails.get(record.agent_id) ?? Promise.resolve(); + const work = prior.catch(() => undefined).then(execute).finally(() => { + inFlight.delete(record.acceptance_id); + if (agentTails.get(record.agent_id) === work) { + agentTails.delete(record.agent_id); + void schedules?.drain(record.agent_id); + } + }); inFlight.set(record.acceptance_id, work); + agentTails.set(record.agent_id, work); }; + const persistRequest = async (request: OrganizationRuntimeWakeAcceptanceRequest): Promise => { + try { + const accepted = await store!.accept(request); dispatch(accepted.record); return publicAcceptance(accepted.record); + } catch (error) { + if (error instanceof WakeAcceptanceConflictError) return { version: "noopolis.daimon.wake-acceptance.v2", state: "rejected", code: "delivery_conflict" }; + throw error; + } + }; + const acceptRequest = async (request: OrganizationRuntimeWakeAcceptanceRequest): Promise => + await serializeAcceptance(request.agent_id, async () => await persistRequest(request)); + return { wake: async (request) => await host.wake(request), health: async (agentId) => await host.health(agentId), @@ -88,6 +157,19 @@ function createControl(config: OrganizationRuntimeConfig, host: CoreHost, option store = opened; started = true; for (const record of await opened.recoverable(knownAgents)) dispatch(record); + if (config.version === "noopolis.daimon.organization-runtime.v2") { + schedules = createScheduleController({ + acceptanceStorePath: options.acceptanceStorePath, agents: config.agents, + ...options.scheduleOptions, + accept: async (occurrence) => await serializeAcceptance(occurrence.agentId, async () => { + if (agentTails.has(occurrence.agentId)) return false; + const accepted = await persistRequest({ token: expectedToken, agent_id: occurrence.agentId, delivery_id: occurrence.deliveryId, event: { version: "noopolis.daimon.wake.v2", kind: "schedule", text: occurrence.prompt, occurred_at: occurrence.occurredAt } }); + if (accepted.state !== "accepted") throw new Error(`scheduled wake ${occurrence.deliveryId} was not durably accepted`); + return true; + }) + }); + await schedules.start(); + } } catch (error) { await host.stop().catch(() => undefined); await opened.close().catch(() => undefined); @@ -100,26 +182,26 @@ function createControl(config: OrganizationRuntimeConfig, host: CoreHost, option if (!tokensEqual(expectedToken, request.token)) return { version: "noopolis.daimon.wake-acceptance.v2", state: "rejected", code: "unauthorized" }; if (!started || stopping) return { version: "noopolis.daimon.wake-acceptance.v2", state: "stopped", code: stopping ? "host_stopping" : "host_stopped" }; if (!knownAgents.has(request.agent_id)) return { version: "noopolis.daimon.wake-acceptance.v2", state: "rejected", code: "unknown_agent" }; - try { - const accepted = await store!.accept(request); - dispatch(accepted.record); - return publicAcceptance(accepted.record); - } catch (error) { - if (error instanceof WakeAcceptanceConflictError) return { version: "noopolis.daimon.wake-acceptance.v2", state: "rejected", code: "delivery_conflict" }; - throw error; - } + return await acceptRequest(request); }, async wakeReceipt(token: string | undefined, acceptanceId: string): Promise { if (!tokensEqual(expectedToken, token) || store === undefined) return undefined; return await store.status(acceptanceId); }, + async activityV2(token: string | undefined): Promise { + if (!tokensEqual(expectedToken, token) || store === undefined) return undefined; + return { version: ACTIVITY_V2_VERSION, items: await store.activity() }; + }, async stop(): Promise { stopping = true; + for (const wake of retryWaiters) wake(); + await schedules?.stop(); const result = await host.stop(); await Promise.allSettled(inFlight.values()); await store?.releaseClaims(ownerId); await store?.close(); store = undefined; + schedules = undefined; started = false; return result; } diff --git a/src/runtime/organizationRuntimeHost.test.ts b/src/runtime/organizationRuntimeHost.test.ts index 6888f40..96eed95 100644 --- a/src/runtime/organizationRuntimeHost.test.ts +++ b/src/runtime/organizationRuntimeHost.test.ts @@ -181,6 +181,25 @@ test("authenticates targeted wakes and exposes bounded activity", async () => { delete process.env[tokenEnv]; }); +test("v1 wake results redact provider credentials before publication", async () => { + process.env[tokenEnv] = "test-token"; + const rawKey = "grok-access-key-secret-1234567890"; + const host = createOrganizationRuntimeHostForTest(config(["alpha"]), async () => { + const handle = new FakeHandle(); + handle.wake = async () => ({ agentId: "alpha", text: JSON.stringify({ key: rawKey, refresh_token: rawKey }), durationMs: 1 }); + return handle; + }); + await host.start(); + const result = await host.wake(wake("alpha", "redact-v1")); + assert.equal(result.status, "completed"); + if (result.status === "completed") { + assert.doesNotMatch(result.text, /grok-access-key/u); + assert.match(result.text, /\[REDACTED\]/u); + } + await host.stop(); + delete process.env[tokenEnv]; +}); + test("stop rejects queued work, aborts active work, and is idempotent", async () => { process.env[tokenEnv] = "test-token"; const handle = new FakeHandle(true); diff --git a/src/runtime/organizationRuntimeHost.ts b/src/runtime/organizationRuntimeHost.ts index 04112a6..c99343d 100644 --- a/src/runtime/organizationRuntimeHost.ts +++ b/src/runtime/organizationRuntimeHost.ts @@ -1,11 +1,14 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import type { AgentHandle, AgentStatus, WakeEvent } from "../core/types.js"; +import { sanitizeWakeCompletionText } from "./wakeAcceptanceTypes.js"; -import { startAgySubscriptionRealm, type AgySubscriptionRealm } from "./agySubscriptionRealm.js"; import { startOrganizationRuntimeEngine } from "./engineDispatcher.js"; -import { prepareEngineExecutable, verifyAgySubscriptionEnrollment } from "./engineReadiness.js"; -import { prepareOrganizationRuntimePaths, type OrganizationRuntimePathAuthority } from "./physicalReadiness.js"; +import type { OrganizationRuntimePathAuthority } from "./physicalReadiness.js"; +import { + prepareProductionReadiness, + type OrganizationRuntimeHostReadiness +} from "./organizationRuntimeReadiness.js"; import { parseOrganizationRuntimeConfig, parseOrganizationRuntimeWakeRequest, @@ -28,11 +31,8 @@ type OrganizationRuntimeEngineFactory = ( paths?: ReturnType ) => Promise; -type HostReadiness = Readonly<{ - agyRealm?: AgySubscriptionRealm; - paths: OrganizationRuntimePathAuthority; - close(): Promise; -}>; +type HostReadiness = OrganizationRuntimeHostReadiness; +type ProductionHostOptions = Readonly<{ sharedProtectedPaths?: readonly string[] }>; type WakeJob = { readonly request: OrganizationRuntimeWakeRequest; @@ -52,19 +52,21 @@ type HostedAgent = { }; /** Creates the public host with Daimon's closed production engine dispatcher. */ -export function createOrganizationRuntimeHost(config: unknown): OrganizationRuntimeHost { +export function createOrganizationRuntimeHost(config: unknown, options: ProductionHostOptions = {}): OrganizationRuntimeHost { const parsed = parseOrganizationRuntimeConfig(config); let agyBusAddress: string | undefined; + let grokBroker: OrganizationRuntimeHostReadiness["grokBroker"]; return createHost( parsed, - (agent, paths) => startOrganizationRuntimeEngine(agent, parsed.host.controlTokenEnv, paths, agyBusAddress), + (agent, paths) => startOrganizationRuntimeEngine(agent, parsed.host.controlTokenEnv, paths, agyBusAddress, grokBroker, parsed.agents, options.sharedProtectedPaths), async () => { const ready = await prepareProductionReadiness(parsed); agyBusAddress = ready.agyRealm?.busAddress; + grokBroker = ready.grokBroker; return { ...ready, async close() { - try { await ready.close(); } finally { agyBusAddress = undefined; } + try { await ready.close(); } finally { agyBusAddress = undefined; grokBroker = undefined; } } }; } @@ -83,40 +85,6 @@ export function createOrganizationRuntimeHostForTest( }); } -async function prepareProductionReadiness(config: OrganizationRuntimeConfig): Promise { - const paths = await prepareOrganizationRuntimePaths(config.agents); - let realm: AgySubscriptionRealm | undefined; - try { - const agy = config.agents.find((agent) => agent.engine.kind === "agy"); - if (agy !== undefined) { - realm = await startAgySubscriptionRealm(); - const canonical = paths.forAgent(agy); - await canonical.verify(); - const executable = await prepareEngineExecutable(agy.id, "agy"); - await verifyAgySubscriptionEnrollment(agy.id, executable.executablePath, canonical.runtimeHomePath, realm.busAddress); - await executable.verify(); - await canonical.verify(); - } - let closed = false; - return { - ...(realm === undefined ? {} : { agyRealm: realm }), - paths, - async close() { - if (closed) return; - closed = true; - const results = await Promise.allSettled([realm?.close(), paths.close()].filter((value): value is Promise => value !== undefined)); - const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []); - if (failures.length > 0) throw new AggregateError(failures, "organization runtime readiness cleanup failed"); - } - }; - } catch (error) { - const cleanup = await Promise.allSettled([realm?.close(), paths.close()].filter((value): value is Promise => value !== undefined)); - const failures = cleanup.flatMap((result) => result.status === "rejected" ? [result.reason] : []); - if (failures.length > 0) throw new AggregateError([error, ...failures], "organization runtime readiness failed and cleanup was incomplete"); - throw error; - } -} - function createHost( config: OrganizationRuntimeConfig, factory: OrganizationRuntimeEngineFactory, @@ -297,7 +265,7 @@ function createHost( state, agents: [...agents.values()] .filter((agent) => agentId === undefined || agent.config.id === agentId) - .map((agent) => ({ agentId: agent.config.id, state: agentHealthState(agent) })) + .map((agent) => ({ agentId: agent.config.id, engine: agent.config.engine.kind, state: agentHealthState(agent) })) }); const activityPage = async (request: OrganizationRuntimeActivityRequest): Promise => { @@ -395,7 +363,7 @@ function cursorOffset(cursor: string | undefined): number { } function completed(request: OrganizationRuntimeWakeRequest, text: string, durationMs: number): OrganizationRuntimeWakeResult { - return { version: "noopolis.daimon.wake-result.v1", status: "completed", agentId: request.agentId, wakeId: request.event.id, text, durationMs }; + return { version: "noopolis.daimon.wake-result.v1", status: "completed", agentId: request.agentId, wakeId: request.event.id, text: sanitizeWakeCompletionText(text), durationMs }; } function failed(request: OrganizationRuntimeWakeRequest): OrganizationRuntimeWakeResult { diff --git a/src/runtime/organizationRuntimeParsing.ts b/src/runtime/organizationRuntimeParsing.ts index 37babe3..3f7970e 100644 --- a/src/runtime/organizationRuntimeParsing.ts +++ b/src/runtime/organizationRuntimeParsing.ts @@ -4,24 +4,29 @@ import { ORGANIZATION_RUNTIME_MAX_STRING_BYTES, ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, ORGANIZATION_RUNTIME_MAX_WAKE_TEXT_BYTES, + ORGANIZATION_RUNTIME_MAX_SCHEDULE_INTERVAL_MS, + ORGANIZATION_RUNTIME_V2_VERSION, ORGANIZATION_RUNTIME_VERSION, type OrganizationRuntimeAgentConfig, type OrganizationRuntimeConfig, type OrganizationRuntimeEngineIntent, type OrganizationRuntimeEngineKind, type OrganizationRuntimeHostConfig, + type OrganizationRuntimeSchedule, type OrganizationRuntimeWakeRequest } from "./organizationRuntime.js"; type RecordValue = Record; const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const ENGINE_KINDS = new Set(["codex", "grok", "agy"]); +const CRON_FIELD_BOUNDS = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]] as const; export function parseOrganizationRuntimeConfig(value: unknown): OrganizationRuntimeConfig { const root = object(snapshot(value, "config"), "config"); size(root); exact(root, ["version", "host", "agents"], "config"); - if (string(root.version, "config.version") !== ORGANIZATION_RUNTIME_VERSION) throw new TypeError(`config.version must equal ${ORGANIZATION_RUNTIME_VERSION}`); + const version = string(root.version, "config.version"); + if (version !== ORGANIZATION_RUNTIME_VERSION && version !== ORGANIZATION_RUNTIME_V2_VERSION) throw new TypeError("config.version is not supported"); const hostValue = object(root.host, "config.host"); exact(hostValue, ["bindHost", "port", "controlTokenEnv"], "config.host"); const host: OrganizationRuntimeHostConfig = { @@ -31,13 +36,13 @@ export function parseOrganizationRuntimeConfig(value: unknown): OrganizationRunt if (rawAgents.length === 0 || rawAgents.length > ORGANIZATION_RUNTIME_MAX_AGENTS) throw new TypeError(`config.agents must contain between 1 and ${ORGANIZATION_RUNTIME_MAX_AGENTS} agents`); const ids = new Set(); const agents = rawAgents.map((item, index) => { - const agent = parseAgent(item, `config.agents[${index}]`); + const agent = parseAgent(item, `config.agents[${index}]`, version === ORGANIZATION_RUNTIME_V2_VERSION); if (ids.has(agent.id)) throw new TypeError(`config.agents has duplicate id ${agent.id}`); ids.add(agent.id); return agent; }); isolated(agents); - return { version: ORGANIZATION_RUNTIME_VERSION, host, agents }; + return { version: version as OrganizationRuntimeConfig["version"], host, agents }; } export function parseOrganizationRuntimeWakeRequest(value: unknown): OrganizationRuntimeWakeRequest { @@ -47,7 +52,7 @@ export function parseOrganizationRuntimeWakeRequest(value: unknown): Organizatio exact(event, ["version", "id", "kind", "text", "occurredAt"], "wake request.event"); if (string(event.version, "wake request.event.version") !== "noopolis.daimon.wake.v1") throw new TypeError("wake request.event.version is not supported"); const kind = string(event.kind, "wake request.event.kind"); - if (kind !== "manual" && kind !== "message" && kind !== "external") throw new TypeError("wake request.event.kind is not supported"); + if (kind !== "manual" && kind !== "message" && kind !== "schedule" && kind !== "external") throw new TypeError("wake request.event.kind is not supported"); const text = string(event.text, "wake request.event.text"); if (Buffer.byteLength(text, "utf8") > ORGANIZATION_RUNTIME_MAX_WAKE_TEXT_BYTES) throw new TypeError("wake request.event.text exceeds the wake text limit"); return { token: request.token === undefined ? undefined : string(request.token, "wake request.token"), agentId: nonEmpty(request.agentId, "wake request.agentId"), event: { version: "noopolis.daimon.wake.v1", id: nonEmpty(event.id, "wake request.event.id"), kind, text, occurredAt: rfc3339(event.occurredAt) } }; @@ -59,10 +64,96 @@ export function validateOrganizationRuntimeConfig(value: unknown): value is Orga export const isOrganizationRuntimeConfig = validateOrganizationRuntimeConfig; -function parseAgent(value: unknown, label: string): OrganizationRuntimeAgentConfig { +function parseAgent(value: unknown, label: string, v2: boolean): OrganizationRuntimeAgentConfig { const agent = object(value, label); - exact(agent, ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine"], label); - return { id: nonEmpty(agent.id, `${label}.id`), name: nonEmpty(agent.name, `${label}.name`), instructions: nonEmpty(agent.instructions, `${label}.instructions`), workspacePath: absolute(agent.workspacePath, `${label}.workspacePath`), runtimeHomePath: absolute(agent.runtimeHomePath, `${label}.runtimeHomePath`), engine: engine(agent.engine, `${label}.engine`) }; + exactOptional(agent, v2 ? ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine", "schedule"] : ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine"], ["mcp", "moltnet"], label); + return { + id: nonEmpty(agent.id, `${label}.id`), name: nonEmpty(agent.name, `${label}.name`), instructions: nonEmpty(agent.instructions, `${label}.instructions`), workspacePath: absolute(agent.workspacePath, `${label}.workspacePath`), runtimeHomePath: absolute(agent.runtimeHomePath, `${label}.runtimeHomePath`), engine: engine(agent.engine, `${label}.engine`), + ...(v2 ? { schedule: schedule(agent.schedule, `${label}.schedule`) } : {}), + ...(agent.mcp === undefined ? {} : { mcp: mcpServers(agent.mcp, `${label}.mcp`) }), + ...(agent.moltnet === undefined ? {} : { moltnet: moltnet(agent.moltnet, `${label}.moltnet`) }) + }; +} + +function mcpServers(value: unknown, label: string): OrganizationRuntimeAgentConfig["mcp"] { + const rows = array(value, label); if (rows.length > 8) throw new TypeError(`${label} exceeds server limit`); + const names = new Set(); + return rows.map((value, index) => { + const item = object(value, `${label}[${index}]`); exactOptional(item, ["name", "transport", "args", "env", "tools"], ["command", "url", "authSecretEnv"], `${label}[${index}]`); + const name = nonEmpty(item.name, `${label}[${index}].name`); if (names.has(name)) throw new TypeError(`${label} has duplicate name`); names.add(name); + const transport = string(item.transport, `${label}[${index}].transport`); if (!["stdio", "sse", "streamable_http"].includes(transport)) throw new TypeError(`${label} transport is invalid`); + const args = array(item.args, `${label}[${index}].args`).map((entry) => string(entry, `${label}.args`)); if (args.length > 32) throw new TypeError(`${label} args exceed limit`); + const envInput = object(item.env, `${label}[${index}].env`); const env = Object.fromEntries(Object.entries(envInput).map(([key, entry]) => [envName(key, `${label}.env key`), string(entry, `${label}.env.${key}`)])); + const tools = array(item.tools, `${label}[${index}].tools`).map((entry) => nonEmpty(entry, `${label}.tools`)); if (tools.length === 0 || tools.length > 32 || new Set(tools).size !== tools.length) throw new TypeError(`${label} tools are invalid`); + const command = item.command === undefined ? undefined : absolute(item.command, `${label}.command`); const url = item.url === undefined ? undefined : nonEmpty(item.url, `${label}.url`); + if ((transport === "stdio") !== (command !== undefined) || (transport === "stdio") === (url !== undefined)) throw new TypeError(`${label} endpoint is invalid`); + if (url !== undefined) { const parsed = new URL(url); if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && ["127.0.0.1", "localhost"].includes(parsed.hostname))) throw new TypeError(`${label}.url must be HTTPS or loopback HTTP`); } + return { name, transport: transport as "stdio" | "sse" | "streamable_http", args, env, tools, ...(command ? { command } : {}), ...(url ? { url } : {}), ...(item.authSecretEnv === undefined ? {} : { authSecretEnv: envName(item.authSecretEnv, `${label}.authSecretEnv`) }) }; + }); +} + +function moltnet(value: unknown, label: string): NonNullable { + const item = object(value, label); exact(item, ["cliPath", "configPath", "networks"], label); + const networks = array(item.networks, `${label}.networks`).map((value, index) => { const row = object(value, `${label}.networks[${index}]`); exact(row, ["id", "rooms", "dms"], `${label}.networks[${index}]`); return { id: nonEmpty(row.id, `${label}.id`), rooms: array(row.rooms, `${label}.rooms`).map((room) => nonEmpty(room, `${label}.room`)), dms: row.dms === true }; }); + if (networks.length > 16 || new Set(networks.map((entry) => entry.id)).size !== networks.length) throw new TypeError(`${label}.networks are invalid`); + return { cliPath: absolute(item.cliPath, `${label}.cliPath`), configPath: absolute(item.configPath, `${label}.configPath`), networks }; +} + +function schedule(value: unknown, label: string): OrganizationRuntimeSchedule { + const input = object(value, label); const kind = string(input.kind, `${label}.kind`); + if (kind === "disabled") { exact(input, ["kind"], label); return { kind }; } + if (kind === "every") { + exact(input, ["kind", "interval_ms", "prompt"], label); + if (typeof input.interval_ms !== "number" || !Number.isInteger(input.interval_ms) || input.interval_ms < 1 || input.interval_ms > ORGANIZATION_RUNTIME_MAX_SCHEDULE_INTERVAL_MS) throw new TypeError(`${label}.interval_ms is outside its bound`); + return { kind, interval_ms: input.interval_ms, prompt: nonEmpty(input.prompt, `${label}.prompt`) }; + } + if (kind === "cron") { + exact(input, ["kind", "cron", "timezone", "prompt"], label); + const cron = nonEmpty(input.cron, `${label}.cron`).trim().replace(/\s+/gu, " "); + if (!validCron(cron) || !cronCalendarPossible(cron)) throw new TypeError(`${label}.cron is invalid or impossible`); + const timezone = nonEmpty(input.timezone, `${label}.timezone`); + try { new Intl.DateTimeFormat("en-US", { timeZone: timezone }); } catch { throw new TypeError(`${label}.timezone is not an IANA timezone`); } + return { kind, cron, timezone, prompt: nonEmpty(input.prompt, `${label}.prompt`) }; + } + throw new TypeError(`${label}.kind is not supported`); +} + +function validCron(value: string): boolean { + const fields = value.split(/\s+/u); + return fields.length === 5 && fields.every((field, index) => validCronField(field, CRON_FIELD_BOUNDS[index]!)); +} + +function validCronField(field: string, [minimum, maximum]: readonly [number, number]): boolean { + return field.split(",").every((part) => { + const pieces = part.split("/"); + const step = Number(pieces[1] ?? 1); + if (pieces.length > 2 || !pieces[0] || (pieces[1] !== undefined && !/^\d+$/u.test(pieces[1])) || !Number.isSafeInteger(step) || step < 1) return false; + const range = pieces[0]!; + if (range === "*") return true; + const bounds = range.split("-"); + if (bounds.length > 2 || !bounds.every((bound) => /^\d+$/u.test(bound))) return false; + const first = Number(bounds[0]); const last = Number(bounds[1] ?? bounds[0]); + return first >= minimum && last <= maximum && first <= last; + }); +} + +function cronCalendarPossible(cron: string): boolean { + const fields = cron.split(/\s+/u).map((field, index) => cronValues(field, CRON_FIELD_BOUNDS[index]!)); + for (let year = 2000; year < 2400; year += 1) for (const month of fields[3]!) { + const days = new Date(Date.UTC(year, month, 0)).getUTCDate(); + for (const day of fields[2]!) if (day <= days && fields[4]!.includes(new Date(Date.UTC(year, month - 1, day)).getUTCDay())) return true; + } + return false; +} + +function cronValues(field: string, [minimum, maximum]: readonly [number, number]): number[] { + const result = new Set(); + for (const part of field.split(",")) { + const [range, rawStep] = part.split("/"); const step = Number(rawStep ?? 1); + const bounds = range === "*" ? [minimum, maximum] : range!.split("-").map(Number); + for (let value = bounds[0]!; value <= (bounds[1] ?? bounds[0]!); value += step) result.add(value === 7 && maximum === 7 ? 0 : value); + } + return [...result]; } function engine(value: unknown, label: string): OrganizationRuntimeEngineIntent { @@ -109,6 +200,7 @@ function object(value: unknown, label: string): RecordValue { } function array(value: unknown, label: string): readonly unknown[] { if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`); return value; } function exact(value: RecordValue, expected: readonly string[], label: string): void { const extras = Object.keys(value).filter((key) => !expected.includes(key)); const missing = expected.filter((key) => !Object.hasOwn(value, key)); if (extras.length || missing.length) throw new TypeError(`${label} must contain exactly ${expected.join(", ")}`); } +function exactOptional(value: RecordValue, required: readonly string[], optional: readonly string[], label: string): void { const extras = Object.keys(value).filter((key) => !required.includes(key) && !optional.includes(key)); const missing = required.filter((key) => !Object.hasOwn(value, key)); if (extras.length || missing.length) throw new TypeError(`${label} has invalid fields`); } function isolated(agents: readonly OrganizationRuntimeAgentConfig[]): void { const paths = agents.flatMap((agent) => [{ agentId: agent.id, kind: "workspacePath", value: agent.workspacePath }, { agentId: agent.id, kind: "runtimeHomePath", value: agent.runtimeHomePath }]); for (let left = 0; left < paths.length; left += 1) for (let right = left + 1; right < paths.length; right += 1) { const first = paths[left]!; const second = paths[right]!; if (first.value === second.value || first.value.startsWith(`${second.value}/`) || second.value.startsWith(`${first.value}/`)) throw new TypeError(`agents ${first.agentId}.${first.kind} and ${second.agentId}.${second.kind} must not overlap`); } } function string(value: unknown, label: string): string { if (typeof value !== "string") throw new TypeError(`${label} must be a string`); if (Buffer.byteLength(value, "utf8") > ORGANIZATION_RUNTIME_MAX_STRING_BYTES || Array.from(value).length > ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS) throw new TypeError(`${label} exceeds the runtime string limit`); return value; } function nonEmpty(value: unknown, label: string): string { const result = string(value, label); if (!result.trim()) throw new TypeError(`${label} must not be empty`); return result; } diff --git a/src/runtime/schedule.test.ts b/src/runtime/schedule.test.ts new file mode 100644 index 0000000..78f0269 --- /dev/null +++ b/src/runtime/schedule.test.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { chmod, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { createScheduleController, MAX_TIMER_DELAY_MS, nextOccurrence, occurrenceFor } from "./schedule.js"; +import { WakeAcceptanceStore } from "./wakeAcceptanceStore.js"; +import { parseWakeAcceptanceRequest } from "./wakeAcceptanceTypes.js"; + +const every = { kind: "every" as const, interval_ms: 60_000, prompt: "work" }; +const agent = (schedule: typeof every | { kind: "cron"; cron: string; timezone: string; prompt: string } | { kind: "disabled" }) => + ({ id: "alpha", name: "Alpha", instructions: "work", workspacePath: "/workspace/alpha", runtimeHomePath: "/home/alpha", engine: { kind: "codex" as const }, schedule }); + +test("every schedules keep a stable anchor and coalesce downtime to the latest cadence", () => { + assert.equal(nextOccurrence("alpha", every, 0, 305_000), 360_000); + assert.equal(nextOccurrence("alpha", every, 0, 59_000), 60_000); +}); + +test("cron field origins, impossible dates, and sparse leap work are deterministic", () => { + const stepped = { kind: "cron" as const, cron: "0 0 */2 */2 *", timezone: "UTC", prompt: "work" }; + assert.equal(nextOccurrence("alpha", stepped, undefined, Date.parse("2026-01-01T00:00:00.000Z")), Date.parse("2026-01-03T00:00:00.000Z")); + const leap = { kind: "cron" as const, cron: "0 0 29 2 *", timezone: "UTC", prompt: "work" }; + assert.equal(nextOccurrence("alpha", leap, undefined, Date.parse("2025-03-01T00:00:00.000Z")), Date.parse("2028-02-29T00:00:00.000Z")); + assert.throws(() => nextOccurrence("alpha", { ...leap, cron: "0 0 31 2 *" }, undefined, 0), /impossible/); +}); + +test("cron forward search chooses the immediately next minute from a partial minute", () => { + const schedule = { kind: "cron" as const, cron: "* * * * *", timezone: "UTC", prompt: "work" }; + assert.equal(nextOccurrence("alpha", schedule, undefined, Date.parse("2026-01-01T00:00:30.500Z")), Date.parse("2026-01-01T00:01:00.000Z")); + assert.equal(nextOccurrence("alpha", { ...schedule, cron: " * * * * * " }, undefined, Date.parse("2026-01-01T00:00:30.500Z")), Date.parse("2026-01-01T00:01:00.000Z")); + assert.throws(() => nextOccurrence("alpha", { ...schedule, cron: `*/${"9".repeat(400)} * * * *` }, undefined, 0), /invalid/); +}); + +test("cron occurrence identities include the named-zone offset across DST fall-back", () => { + const schedule = { kind: "cron" as const, cron: "30 2 * * *", timezone: "Europe/Berlin", prompt: "work" }; + const first = occurrenceFor("alpha", schedule, Date.parse("2026-10-25T00:30:00.000Z")); + const second = occurrenceFor("alpha", schedule, Date.parse("2026-10-25T01:30:00.000Z")); + assert.notEqual(first.deliveryId, second.deliveryId); +}); + +test("cron skips nonexistent spring time and restart picks the second fall occurrence", () => { + const schedule = { kind: "cron" as const, cron: "30 2 * * *", timezone: "Europe/Berlin", prompt: "work" }; + assert.equal(nextOccurrence("alpha", schedule, undefined, Date.parse("2026-03-29T00:00:00.000Z")), Date.parse("2026-03-30T00:30:00.000Z")); + assert.equal(nextOccurrence("alpha", schedule, undefined, Date.parse("2026-10-25T00:31:00.000Z")), Date.parse("2026-10-25T01:30:00.000Z")); +}); + +test("cron combines restricted day-of-month and day-of-week with standard OR semantics across DST", () => { + const schedule = { kind: "cron" as const, cron: "0 0 1 * 1", timezone: "Europe/Berlin", prompt: "work" }; + assert.equal(nextOccurrence("alpha", schedule, undefined, Date.parse("2026-03-28T00:00:00.000Z")), Date.parse("2026-03-29T22:00:00.000Z")); + assert.equal(nextOccurrence("alpha", { ...schedule, cron: "0 0 * * 1" }, undefined, Date.parse("2026-03-28T00:00:00.000Z")), Date.parse("2026-03-29T22:00:00.000Z")); + assert.equal(nextOccurrence("alpha", { ...schedule, cron: "0 0 1 * *" }, undefined, Date.parse("2026-03-28T00:00:00.000Z")), Date.parse("2026-03-31T22:00:00.000Z")); +}); + +test("state remains inside the mounted root and replacement drains a missed occurrence once", async () => { + const root = await privateRoot(); + let now = 0; + const timers = fakeTimers(); + try { + const first = createScheduleController({ acceptanceStorePath: root, agents: [agent(every)], accept: async () => false, now: () => now, ...timers.options }); + await first.start(); + assert.equal((await stat(path.join(root, "schedule-state.v1.json"))).mode & 0o777, 0o600); + await first.stop(); + now = 181_000; + const accepted: ReturnType[] = []; + const replacement = createScheduleController({ acceptanceStorePath: root, agents: [agent(every)], accept: async (occurrence) => { accepted.push(occurrence); }, now: () => now, ...fakeTimers().options }); + await replacement.start(); await replacement.stop(); + assert.deepEqual(accepted, [occurrenceFor("alpha", every, 180_000)]); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("every cadence samples now once and persists the immediate next 1ms occurrence", async () => { + const root = await privateRoot(); const oneMs = { ...every, interval_ms: 1 }; let tick = 0; + try { + await seedState(root, oneMs, 1); + const controller = createScheduleController({ acceptanceStorePath: root, agents: [agent(oneMs)], accept: async () => false, now: () => ++tick, ...fakeTimers().options }); + await controller.start(); await controller.stop(); + const state = JSON.parse(await readState(root)); const entry = state.schedules[Object.keys(state.schedules)[0]]; + assert.equal(entry.next_due_ms, 2); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("state persistence orders write, file sync, rename, and directory sync", async () => { + const root = await privateRoot(); const stages: string[] = []; + try { + const controller = createScheduleController({ acceptanceStorePath: root, agents: [agent(every)], accept: async () => false, now: () => 0, onPersistStageForTest: (stage) => stages.push(stage), ...fakeTimers().options }); + await controller.start(); await controller.stop(); + assert.deepEqual(stages, ["write", "file-sync", "rename", "directory-sync"]); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("busy fires durably replace latest pending and restart drains only the newest", async () => { + const root = await privateRoot(); let now = 0; const timers = fakeTimers(); + try { + const busy = createScheduleController({ acceptanceStorePath: root, agents: [agent(every)], accept: async () => false, now: () => now, ...timers.options }); + await busy.start(); + now = 61_000; timers.fire(); + await eventually(async () => { + const value = JSON.parse(await readState(root)); const entry = value.schedules[Object.keys(value.schedules)[0]]; + return entry?.latest_pending?.occurredAt === new Date(60_000).toISOString(); + }); + now = 121_000; timers.fire(); + await eventually(async () => (await readState(root)).includes(new Date(120_000).toISOString())); + await busy.stop(); + const accepted: ReturnType[] = []; + const replacement = createScheduleController({ acceptanceStorePath: root, agents: [agent(every)], accept: async (occurrence) => { accepted.push(occurrence); }, now: () => now, ...fakeTimers().options }); + await replacement.start(); + await Promise.all([replacement.drain("alpha"), replacement.drain("alpha")]); + await replacement.stop(); + assert.deepEqual(accepted, [occurrenceFor("alpha", every, 120_000)]); + assert.equal((await readdir(root)).some((file) => file.startsWith(".schedule-state-")), false); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("restored pending work is bound to its exact agent, schedule, identity, and due time", async (context) => { + for (const [name, mutate] of [ + ["agent", (pending: Record, _entry: Record) => { pending.agentId = "beta"; }], + ["prompt", (pending: Record, _entry: Record) => { pending.prompt = "rerouted"; }], + ["delivery", (pending: Record, _entry: Record) => { pending.deliveryId = `${pending.deliveryId as string}:changed`; }], + ["timestamp", (pending: Record, _entry: Record) => { pending.occurredAt = new Date(61_000).toISOString(); }], + ["due", (_pending: Record, entry: Record) => { entry.next_due_ms = 180_000; }] + ] as const) { + await context.test(name, async () => { + const root = await privateRoot(); const timers = fakeTimers(); let now = 0; + try { + const controller = createScheduleController({ acceptanceStorePath: root, agents: [agent(every)], accept: async () => false, now: () => now, ...timers.options }); + await controller.start(); now = 61_000; timers.fire(); + await eventually(async () => (await readState(root)).includes("latest_pending")); + await controller.stop(); + const state = JSON.parse(await readState(root)) as { schedules: Record> }; + const entry = state.schedules[Object.keys(state.schedules)[0]!]!; + mutate(entry.latest_pending as Record, entry); + await writeFile(path.join(root, "schedule-state.v1.json"), JSON.stringify(state), { mode: 0o600 }); + const replacement = createScheduleController({ acceptanceStorePath: root, agents: [agent(every)], accept: async () => true, now: () => now, ...fakeTimers().options }); + await assert.rejects(replacement.start(), /schedule state is invalid/); + await replacement.stop(); + } finally { await rm(root, { recursive: true, force: true }); } + }); + } +}); + +test("timers above Node's maximum are armed in bounded chunks", async () => { + const root = await privateRoot(); const delays: number[] = []; + try { + const controller = createScheduleController({ + acceptanceStorePath: root, agents: [agent({ ...every, interval_ms: 31_536_000_000 })], accept: async () => undefined, now: () => 0, + setTimer: ((callback: () => void, delay: number) => { delays.push(delay); return { callback, unref() {} } as never; }), clearTimer: () => undefined + }); + await controller.start(); await controller.stop(); + assert.deepEqual(delays, [MAX_TIMER_DELAY_MS]); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("disabled schedules persist no pending work", async () => { + const root = await privateRoot(); const accepted: unknown[] = []; + try { + const controller = createScheduleController({ acceptanceStorePath: root, agents: [agent({ kind: "disabled" })], accept: async (value) => { accepted.push(value); } }); + await controller.start(); await controller.stop(); assert.deepEqual(accepted, []); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("post-acceptance cut deduplicates one stable occurrence on a fresh replacement", async () => { + const root = await privateRoot(); const now = 61_000; const storeOptions = { + processIdentity: async () => ({ pid: 1, process_start: "schedule-test", boot_id: "test-boot", pid_namespace_dev: 1, pid_namespace_ino: 1 }), + ownerLiveness: async () => true + }; + try { + await seedState(root, every, 60_000); + const firstStore = await WakeAcceptanceStore.open(root, storeOptions); + let durableAcceptanceId: string | undefined; + const crash = createScheduleController({ acceptanceStorePath: root, agents: [agent(every)], accept: async (occurrence) => { + const accepted = await firstStore.accept(scheduleRequest(occurrence)); + assert.equal(accepted.created, true); durableAcceptanceId = accepted.record.acceptance_id; + throw new Error("forced post-acceptance cut"); + }, now: () => now, ...fakeTimers().options }); + await assert.rejects(crash.start(), /forced post-acceptance cut/); await crash.stop(); await firstStore.close(); + + const replacementStore = await WakeAcceptanceStore.open(root, storeOptions); + const drained: string[] = []; + const replacement = createScheduleController({ acceptanceStorePath: root, agents: [agent(every)], accept: async (occurrence) => { + drained.push(occurrence.deliveryId); + const duplicate = await replacementStore.accept(scheduleRequest(occurrence)); + assert.equal(duplicate.created, false); assert.equal(duplicate.record.acceptance_id, durableAcceptanceId); + return true; + }, now: () => now, ...fakeTimers().options }); + await replacement.start(); await replacement.drain("alpha"); await replacement.stop(); + assert.deepEqual(drained, [occurrenceFor("alpha", every, 60_000).deliveryId]); + assert.equal((await replacementStore.recoverable(new Set(["alpha"]))).length, 1); + assert.equal((await readState(root)).includes("latest_pending"), false); + await replacementStore.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +async function privateRoot(): Promise { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-schedule-")); await chmod(root, 0o700); return root; } +async function readState(root: string): Promise { return await import("node:fs/promises").then(({ readFile }) => readFile(path.join(root, "schedule-state.v1.json"), "utf8")); } +async function seedState(root: string, schedule: typeof every, due: number): Promise { + const key = occurrenceFor("alpha", schedule, due).deliveryId.split(":")[1]!; + await writeFile(path.join(root, "schedule-state.v1.json"), JSON.stringify({ version: "noopolis.daimon.schedule-state.v1", schedules: { [key]: { next_due_ms: due } } }), { mode: 0o600 }); +} +function scheduleRequest(occurrence: ReturnType) { + return parseWakeAcceptanceRequest({ token: undefined, agent_id: occurrence.agentId, delivery_id: occurrence.deliveryId, event: { version: "noopolis.daimon.wake.v2", kind: "schedule", text: occurrence.prompt, occurred_at: occurrence.occurredAt } }); +} +function fakeTimers(): { options: { setTimer: (callback: () => void, delay: number) => ReturnType; clearTimer: (timer: ReturnType) => void }; fire(): void } { + let latest: (() => void) | undefined; + return { options: { setTimer: ((callback: () => void) => { latest = callback; return { unref() {} } as never; }), clearTimer: () => undefined }, fire: () => latest?.() }; +} +async function eventually(predicate: () => Promise): Promise { const end = Date.now() + 2_000; while (Date.now() < end) { if (await predicate()) return; await new Promise((resolve) => setTimeout(resolve, 5)); } throw new Error("timed out"); } diff --git a/src/runtime/schedule.ts b/src/runtime/schedule.ts new file mode 100644 index 0000000..31de2c7 --- /dev/null +++ b/src/runtime/schedule.ts @@ -0,0 +1,287 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, open, readFile, rename, unlink } from "node:fs/promises"; +import path from "node:path"; + +import type { OrganizationRuntimeAgentConfig, OrganizationRuntimeSchedule } from "./organizationRuntime.js"; + +const STATE_VERSION = "noopolis.daimon.schedule-state.v1" as const; +const STATE_FILE = "schedule-state.v1.json"; +const MAX_STATE_BYTES = 256 * 1024; +export const MAX_TIMER_DELAY_MS = 2_147_483_647; +const SEARCH_HORIZON_MS = 64 * 366 * 86_400_000; +const MAX_SEARCH_CHECKS = 1_000_000; +const CRON_FIELD_BOUNDS = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]] as const; + +export type ScheduledOccurrence = Readonly<{ agentId: string; deliveryId: string; occurredAt: string; prompt: string }>; +type ActiveSchedule = Exclude; +type Entry = Readonly<{ next_due_ms: number; latest_pending?: ScheduledOccurrence }>; +type State = Readonly<{ version: typeof STATE_VERSION; schedules: Record }>; +type Timer = ReturnType; + +export type ScheduleController = Readonly<{ start(): Promise; drain(agentId?: string): Promise; stop(): Promise }>; +export type ScheduleControllerOptions = Readonly<{ + acceptanceStorePath: string; + agents: readonly OrganizationRuntimeAgentConfig[]; + /** False means the agent is busy; the durable latest-pending slot remains. */ + accept(occurrence: ScheduledOccurrence): Promise; + now?: () => number; + setTimer?: (callback: () => void, delayMs: number) => Timer; + clearTimer?: (timer: Timer) => void; + /** @internal Observable durability stages for focused crash-order tests. */ + onPersistStageForTest?: (stage: "write" | "file-sync" | "rename" | "directory-sync") => void; +}>; + +/** Schedules create durable pending occurrences; engine execution remains elsewhere. */ +export function createScheduleController(options: ScheduleControllerOptions): ScheduleController { + const now = options.now ?? Date.now; + const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay)); + const clearTimer = options.clearTimer ?? clearTimeout; + const statePath = path.join(options.acceptanceStorePath, STATE_FILE); + const active = options.agents.filter((agent) => agent.schedule && agent.schedule.kind !== "disabled") as Array; + const byKey = new Map(active.map((agent) => [identity(agent.id, agent.schedule), agent])); + const timers = new Map(); + let directory: Awaited> | undefined; + let state: State = { version: STATE_VERSION, schedules: {} }; + let mutations: Promise = Promise.resolve(); + let stopped = false; + + const enqueue = (operation: () => Promise): Promise => { + const result = mutations.catch(() => undefined).then(operation); + mutations = result.then(() => undefined, () => undefined); + return result; + }; + const save = async (): Promise => { + if (directory === undefined) throw new Error("schedule state directory is unavailable"); + await persist(statePath, state, directory, options.onPersistStageForTest); + }; + const arm = (key: string): void => { + const prior = timers.get(key); + if (prior !== undefined) clearTimer(prior); + if (stopped) return; + const due = state.schedules[key]?.next_due_ms; + if (due === undefined) return; + const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, due - now())); + const timer = setTimer(() => { void enqueue(async () => await onTimer(key)); }, delay); + timer.unref?.(); + timers.set(key, timer); + }; + const drainKey = async (key: string): Promise => { + if (stopped) return; + const pending = state.schedules[key]?.latest_pending; + if (pending === undefined) return; + const accepted = await options.accept(pending); + if (accepted === false || stopped) return; + const current = state.schedules[key]; + if (current?.latest_pending?.deliveryId !== pending.deliveryId) return; + state = { ...state, schedules: { ...state.schedules, [key]: { next_due_ms: current.next_due_ms } } }; + await save(); + }; + const onTimer = async (key: string): Promise => { + if (stopped) return; + const agent = byKey.get(key); const entry = state.schedules[key]; + if (agent === undefined || entry === undefined) return; + const observedNow = now(); + if (entry.next_due_ms > observedNow) { arm(key); return; } + const elapsed = latestEligibleOccurrence(agent.schedule, entry.next_due_ms, observedNow); + state = { ...state, schedules: { ...state.schedules, [key]: { next_due_ms: nextOccurrence(agent.id, agent.schedule, elapsed, observedNow), latest_pending: occurrenceFor(agent.id, agent.schedule, elapsed) } } }; + await save(); arm(key); await drainKey(key); + }; + + return { + async start(): Promise { + await enqueue(async () => { + if (directory !== undefined) return; + directory = await open(options.acceptanceStorePath, constants.O_RDONLY | directoryFlag() | noFollow()); + state = await restore(statePath, byKey); + const next = { ...state.schedules }; + const observedNow = now(); + for (const [key, agent] of byKey) { + const prior = next[key]; + if (prior === undefined) next[key] = { next_due_ms: nextOccurrence(agent.id, agent.schedule, undefined, observedNow) }; + else if (prior.next_due_ms <= observedNow) { + const elapsed = latestEligibleOccurrence(agent.schedule, prior.next_due_ms, observedNow); + const pending = occurrenceFor(agent.id, agent.schedule, elapsed); + next[key] = { next_due_ms: nextOccurrence(agent.id, agent.schedule, elapsed, observedNow), latest_pending: newer(prior.latest_pending, pending) }; + } + } + state = { version: STATE_VERSION, schedules: next }; + await save(); + for (const key of byKey.keys()) arm(key); + for (const key of byKey.keys()) await drainKey(key); + }); + }, + async drain(agentId?: string): Promise { await enqueue(async () => { for (const [key, agent] of byKey) if (agentId === undefined || agent.id === agentId) await drainKey(key); }); }, + async stop(): Promise { + stopped = true; + for (const timer of timers.values()) clearTimer(timer); + timers.clear(); await mutations; await directory?.close(); directory = undefined; + } + }; +} + +export function occurrenceFor(agentId: string, schedule: ActiveSchedule, at: number): ScheduledOccurrence { + const local = schedule.kind === "cron" ? occurrenceForOffset(at, schedule.timezone) : new Date(at).toISOString(); + return { agentId, occurredAt: new Date(at).toISOString(), prompt: schedule.prompt, deliveryId: `schedule:${identity(agentId, schedule)}:${local}` }; +} + +export function isCanonicalScheduleDeliveryId(value: string): boolean { + if (value.length > 256 || !/^schedule:[a-f0-9]{64}:/u.test(value)) return false; + const occurrence = value.slice("schedule:".length + 64 + 1); + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(occurrence) + || /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}@(?:GMT(?:[+-]\d{2}:\d{2})?|UTC)$/u.test(occurrence); +} + +export function nextOccurrence(agentId: string, schedule: ActiveSchedule, anchor: number | undefined, from: number): number { + if (schedule.kind === "every") { + const base = anchor ?? from; + return base > from ? base : base + (Math.floor((from - base) / schedule.interval_ms) + 1) * schedule.interval_ms; + } + const fields = parseCron(schedule.cron); + if (!calendarPossible(fields)) throw new Error(`schedule ${agentId} is impossible`); + const found = searchCron(fields, schedule.timezone, from, from + SEARCH_HORIZON_MS, 1); + if (found !== undefined) return found; + throw new Error(`schedule ${agentId} has no occurrence within the deterministic search bound`); +} + +export function cronIsPossible(cron: string): boolean { try { return calendarPossible(parseCron(cron)); } catch { return false; } } + +function latestEligibleOccurrence(schedule: ActiveSchedule, due: number, at: number): number { + if (schedule.kind === "every") return due + Math.floor((at - due) / schedule.interval_ms) * schedule.interval_ms; + const result = searchCron(parseCron(schedule.cron), schedule.timezone, Math.max(due, at - SEARCH_HORIZON_MS), at, -1); + if (result === undefined) throw new Error("schedule state has no eligible occurrence within the deterministic search bound"); + return result; +} + +type CronFields = readonly (readonly number[])[] & Readonly<{ dayOfMonthWildcard: boolean; dayOfWeekWildcard: boolean }>; +function parseCron(cron: string): CronFields { + const raw = cron.trim().split(/\s+/u); + if (raw.length !== 5) throw new Error("cron is invalid"); + return Object.assign(raw.map((field, index) => parseField(field, CRON_FIELD_BOUNDS[index]!)), { + dayOfMonthWildcard: raw[2] === "*", dayOfWeekWildcard: raw[4] === "*" + }) as CronFields; +} +function parseField(value: string, [minimum, maximum]: readonly [number, number]): readonly number[] { + const result = new Set(); + for (const part of value.split(",")) { + const [range, rawStep] = part.split("/"); const step = Number(rawStep ?? 1); + const bounds = range === "*" ? [minimum, maximum] : range!.split("-").map(Number); + const first = bounds[0]!; const last = bounds[1] ?? first; + if (!Number.isSafeInteger(step) || step < 1 || !Number.isInteger(first) || !Number.isInteger(last) || first < minimum || last > maximum || first > last) throw new Error("cron is invalid"); + for (let current = first; current <= last; current += step) result.add(current === 7 && maximum === 7 ? 0 : current); + } + return [...result].sort((left, right) => left - right); +} +function calendarPossible(fields: CronFields): boolean { + for (let year = 2000; year < 2400; year += 1) for (const month of fields[3]!) { + const days = new Date(Date.UTC(year, month, 0)).getUTCDate(); + for (let day = 1; day <= days; day += 1) if (dateMatches(fields, { year, month, day, hour: 0, minute: 0, weekday: new Date(Date.UTC(year, month - 1, day)).getUTCDay() })) return true; + } + return false; +} +function searchCron(fields: CronFields, zone: string, start: number, end: number, direction: 1 | -1): number | undefined { + let checks = 0; + const firstDay = Math.floor((direction === 1 ? start : end) / 86_400_000) * 86_400_000; + const lastDay = Math.floor((direction === 1 ? end : start) / 86_400_000) * 86_400_000; + for (let day = firstDay; direction === 1 ? day <= lastDay : day >= lastDay; day += direction * 86_400_000) { + const boundary = [parts(day, zone), parts(day + 86_340_000, zone)]; checks += 2; + if (!boundary.some((value) => dateMatches(fields, value))) continue; + const nextMinute = Math.floor(start / 60_000) * 60_000 + 60_000; + const low = Math.max(direction === 1 ? nextMinute : start, day); const high = Math.min(end, day + 86_340_000); + let candidate = direction === 1 ? Math.ceil(low / 60_000) * 60_000 : Math.floor(high / 60_000) * 60_000; + for (; direction === 1 ? candidate <= high : candidate >= low; candidate += direction * 60_000) { + if (++checks > MAX_SEARCH_CHECKS) throw new Error("cron search exceeded its deterministic work bound"); + if (matches(fields, parts(candidate, zone))) return candidate; + } + } + return undefined; +} +function dateMatches(fields: CronFields, value: Parts): boolean { + if (!fields[3]!.includes(value.month)) return false; + const dom = fields[2]!.includes(value.day); const dow = fields[4]!.includes(value.weekday); + return fields.dayOfMonthWildcard ? dow : fields.dayOfWeekWildcard ? dom : dom || dow; +} +function matches(fields: CronFields, value: Parts): boolean { return fields[0]!.includes(value.minute) && fields[1]!.includes(value.hour) && dateMatches(fields, value); } + +type Parts = Readonly<{ year: number; month: number; day: number; hour: number; minute: number; weekday: number }>; +const formatters = new Map(); +function formatter(zone: string): Intl.DateTimeFormat { + let value = formatters.get(zone); + if (value === undefined) { value = new Intl.DateTimeFormat("en-US", { timeZone: zone, hourCycle: "h23", weekday: "short", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric" }); formatters.set(zone, value); } + return value; +} +function parts(at: number, zone: string): Parts { + const values = formatter(zone).formatToParts(at); const get = (name: string): string => values.find((part) => part.type === name)?.value ?? "0"; + return { year: Number(get("year")), month: Number(get("month")), day: Number(get("day")), hour: Number(get("hour")), minute: Number(get("minute")), weekday: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(get("weekday")) }; +} +function occurrenceForOffset(at: number, zone: string): string { + const value = parts(at, zone); const local = `${value.year}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}T${String(value.hour).padStart(2, "0")}:${String(value.minute).padStart(2, "0")}`; + const key = `${zone}\u0000offset`; let offset = formatters.get(key); + if (offset === undefined) { offset = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "longOffset" }); formatters.set(key, offset); } + return `${local}@${offset.formatToParts(at).find((part) => part.type === "timeZoneName")?.value ?? "UTC"}`; +} +function identity(agentId: string, schedule: OrganizationRuntimeSchedule): string { return createHash("sha256").update(JSON.stringify({ agentId, schedule })).digest("hex"); } +function newer(left: ScheduledOccurrence | undefined, right: ScheduledOccurrence): ScheduledOccurrence { return left !== undefined && Date.parse(left.occurredAt) > Date.parse(right.occurredAt) ? left : right; } + +async function restore( + file: string, + allowed: ReadonlyMap +): Promise { + let bytes: Buffer; + try { + const entry = await lstat(file); + if (!entry.isFile() || entry.isSymbolicLink() || (entry.mode & 0o777) !== 0o600 || entry.size > MAX_STATE_BYTES) throw new Error("schedule state is unsafe"); + bytes = await readFile(file); + } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: STATE_VERSION, schedules: {} }; throw error; } + if (bytes.length > MAX_STATE_BYTES) throw new Error("schedule state exceeds its bound"); + const value: unknown = JSON.parse(bytes.toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("schedule state is invalid"); + const root = value as Record; + if (Object.keys(root).sort().join() !== "schedules,version" || root.version !== STATE_VERSION || !root.schedules || typeof root.schedules !== "object" || Array.isArray(root.schedules)) throw new Error("schedule state is invalid"); + const schedules: Record = {}; + for (const [key, raw] of Object.entries(root.schedules as Record)) { + if (!/^[a-f0-9]{64}$/u.test(key)) throw new Error("schedule state is invalid"); + if (!allowed.has(key)) continue; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("schedule state is invalid"); + const entry = raw as Record; const keys = Object.keys(entry).sort().join(); + if (keys !== "next_due_ms" && keys !== "latest_pending,next_due_ms") throw new Error("schedule state is invalid"); + if (!Number.isSafeInteger(entry.next_due_ms) || (entry.next_due_ms as number) < 0) throw new Error("schedule state is invalid"); + const nextDue = entry.next_due_ms as number; + const pending = entry.latest_pending === undefined ? undefined : parseOccurrence(entry.latest_pending); + if (pending !== undefined) assertRestoredOccurrence(key, allowed.get(key)!, pending, nextDue); + schedules[key] = { next_due_ms: nextDue, ...(pending === undefined ? {} : { latest_pending: pending }) }; + } + return { version: STATE_VERSION, schedules }; +} +function assertRestoredOccurrence( + key: string, + agent: OrganizationRuntimeAgentConfig & { schedule: ActiveSchedule }, + pending: ScheduledOccurrence, + nextDue: number +): void { + const occurred = Date.parse(pending.occurredAt); + const expected = occurrenceFor(agent.id, agent.schedule, occurred); + if (pending.agentId !== agent.id || pending.prompt !== agent.schedule.prompt || + pending.deliveryId !== expected.deliveryId || !pending.deliveryId.startsWith(`schedule:${key}:`) || + nextDue !== nextOccurrence(agent.id, agent.schedule, occurred, occurred)) { + throw new Error("schedule state is invalid"); + } +} +function parseOccurrence(value: unknown): ScheduledOccurrence { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("schedule state is invalid"); + const item = value as Record; + if (Object.keys(item).sort().join() !== "agentId,deliveryId,occurredAt,prompt" || Object.values(item).some((entry) => typeof entry !== "string" || Buffer.byteLength(entry, "utf8") > 16_384)) throw new Error("schedule state is invalid"); + const occurredAt = item.occurredAt as string; + if (Number.isNaN(Date.parse(occurredAt)) || new Date(occurredAt).toISOString() !== occurredAt || !/^schedule:[a-f0-9]{64}:/u.test(item.deliveryId as string)) throw new Error("schedule state is invalid"); + return item as unknown as ScheduledOccurrence; +} +async function persist(file: string, value: State, directory: Awaited>, observe?: ScheduleControllerOptions["onPersistStageForTest"]): Promise { + const bytes = Buffer.from(JSON.stringify(value), "utf8"); + if (bytes.length > MAX_STATE_BYTES) throw new Error("schedule state exceeds its bound"); + const temporary = path.join(path.dirname(file), `.schedule-state-${randomUUID()}`); + const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow(), 0o600); + try { await handle.writeFile(bytes); observe?.("write"); await handle.sync(); observe?.("file-sync"); } finally { await handle.close(); } + try { await rename(temporary, file); observe?.("rename"); await directory.sync(); observe?.("directory-sync"); } finally { await unlink(temporary).catch(() => undefined); } +} +function noFollow(): number { return (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; } +function directoryFlag(): number { return (constants as typeof constants & { O_DIRECTORY?: number }).O_DIRECTORY ?? 0; } diff --git a/src/runtime/wakeAcceptance.test.ts b/src/runtime/wakeAcceptance.test.ts index 9e620b1..4a90b59 100644 --- a/src/runtime/wakeAcceptance.test.ts +++ b/src/runtime/wakeAcceptance.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -7,7 +8,8 @@ import test from "node:test"; import { ORGANIZATION_RUNTIME_VERSION, type OrganizationRuntimeHost, type OrganizationRuntimeWakeRequest } from "./organizationRuntime.js"; import { createOrganizationRuntimeControlHostWithCoreForTest } from "./organizationRuntimeControl.js"; import { WakeAcceptanceStore, WakeTransitionLockBlockedError } from "./wakeAcceptanceStore.js"; -import { parseWakeAcceptanceRequest } from "./wakeAcceptanceTypes.js"; +import { MAX_WAKE_COMPLETION_TEXT_BYTES, parseWakeAcceptanceRequest, wakeAcceptanceDigest } from "./wakeAcceptanceTypes.js"; +import { TERMINAL_RECEIPT_IDEMPOTENCY_HORIZON } from "./wakeAcceptanceRetention.js"; const token = "control-secret"; const config = { @@ -40,10 +42,14 @@ test("acceptance store is durable, idempotent, bounded, and recovers accepted wo const core = new FakeCoreHost(); const restarted = createOrganizationRuntimeControlHostWithCoreForTest(config, core, { acceptanceStorePath: root, controlToken: token, storeOptions: testStoreOptions }); await restarted.start(); - await waitFor(() => core.wakes.length === 1); + await core.waitForWakes(1); core.release(); await waitFor(async () => (await restarted.wakeReceipt(token, accepted.record.acceptance_id))?.state === "completed"); + assert.equal((await restarted.wakeReceipt(token, accepted.record.acceptance_id))?.text, "private"); await restarted.stop(); + const persisted = await WakeAcceptanceStore.open(root, testStoreOptions); + assert.equal((await persisted.status(accepted.record.acceptance_id))?.text, "private"); + await persisted.close(); } finally { await rm(root, { recursive: true, force: true }); } }); @@ -63,11 +69,81 @@ test("control accepts before a fake turn finishes, redacts status, and rejects c assert.equal((await control.accept(request("delivery-1", "different"))).state, "rejected"); core.release(); await waitFor(async () => (await control.wakeReceipt(token, accepted.acceptance_id))?.state === "completed"); + assert.equal((await control.wakeReceipt(token, accepted.acceptance_id))?.text, "private"); assert.equal(await control.wakeReceipt("wrong", accepted.acceptance_id), undefined); await control.stop(); } finally { await rm(root, { recursive: true, force: true }); } }); +test("schedule acceptance preserves its WakeEvent kind through the durable FIFO", async () => { + const root = await privateRoot(); + const core = new FakeCoreHost(); + const control = createOrganizationRuntimeControlHostWithCoreForTest(config, core, { acceptanceStorePath: root, controlToken: token, storeOptions: testStoreOptions }); + try { + await control.start(); + await control.accept({ ...request("scheduled"), event: { ...request().event, kind: "schedule" as const } }); + await waitFor(() => core.wakes.length === 1); + assert.equal(core.wakes[0]?.event.kind, "schedule"); + core.release(); + await control.stop(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("completed replies are bounded and credential-redacted while failures carry no text", async () => { + const root = await privateRoot(); + try { + const store = await WakeAcceptanceStore.open(root, testStoreOptions); + const completed = await store.accept(parseWakeAcceptanceRequest(request("bounded-reply"))); + const completedClaim = await store.acquireClaim(completed.record.acceptance_id, "88888888-8888-4888-8888-888888888888"); + if (completedClaim.state !== "acquired") throw new Error("claim missing"); + await store.transitionClaimed(completed.record.acceptance_id, completedClaim.claim, "running"); + const secret = "sk-proj-abcdefghijklmnopqrstuvwxyz123456"; + await store.transitionClaimed(completed.record.acceptance_id, completedClaim.claim, "completed", undefined, `reply ${secret} ${"é".repeat(MAX_WAKE_COMPLETION_TEXT_BYTES)}`); + const receipt = await store.status(completed.record.acceptance_id); + assert.equal(receipt?.state, "completed"); + assert.equal(receipt?.text?.includes(secret), false); + assert.ok(Buffer.byteLength(receipt?.text ?? "", "utf8") <= MAX_WAKE_COMPLETION_TEXT_BYTES); + + const failed = await store.accept(parseWakeAcceptanceRequest(request("failed-reply"))); + const failedClaim = await store.acquireClaim(failed.record.acceptance_id, "99999999-9999-4999-8999-999999999999"); + if (failedClaim.state !== "acquired") throw new Error("claim missing"); + await store.transitionClaimed(failed.record.acceptance_id, failedClaim.claim, "running"); + await store.transitionClaimed(failed.record.acceptance_id, failedClaim.claim, "failed", "engine_failed"); + assert.equal("text" in (await store.status(failed.record.acceptance_id) ?? {}), false); + await assert.rejects( + store.transitionClaimed(failed.record.acceptance_id, failedClaim.claim, "failed", "engine_failed", "must not persist"), + /completion text requires completed state/ + ); + await store.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("durable inbox stays FIFO, exposes v2 active and queued work, and heartbeats", async () => { + const root = await privateRoot(); + const core = new FakeCoreHost(); + const control = createOrganizationRuntimeControlHostWithCoreForTest(config, core, { + acceptanceStorePath: root, controlToken: token, storeOptions: { ...testStoreOptions, claimTtlMs: 30_000 } + }); + try { + await control.start(); + const first = await control.accept(request("fifo-1")); + const second = await control.accept(request("fifo-2")); + assert.equal(first.state, "accepted"); assert.equal(second.state, "accepted"); + await waitFor(() => core.wakes.length === 1); + const before = await control.activityV2(token); + assert.equal(before?.items.find((item) => item.delivery_id === "fifo-1")?.active, true); + assert.equal(before?.items.find((item) => item.delivery_id === "fifo-2")?.queue_position, 1); + const updated = before?.items.find((item) => item.delivery_id === "fifo-1")?.updated_at; + await waitFor(async () => (await control.activityV2(token))?.items.find((item) => item.delivery_id === "fifo-1")?.updated_at !== updated, 20_000); + assert.equal(core.wakes.length, 1); + core.release(); + await core.waitForWakes(2); + assert.equal(core.wakes[1]?.event.id, "fifo-2"); + core.release(); + await control.stop(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + test("two control hosts cannot execute one accepted delivery concurrently", async () => { const root = await privateRoot(); const firstCore = new FakeCoreHost(); @@ -88,6 +164,72 @@ test("two control hosts cannot execute one accepted delivery concurrently", asyn } finally { await rm(root, { recursive: true, force: true }); } }); +test("a held per-agent head blocks later delivery on a replacement host", async () => { + const root = await privateRoot(); + const firstCore = new FakeCoreHost(); const replacementCore = new FakeCoreHost(); + const leaseOptions = { ...testStoreOptions, claimTtlMs: 1_000 }; + const first = createOrganizationRuntimeControlHostWithCoreForTest(config, firstCore, { acceptanceStorePath: root, controlToken: token, storeOptions: leaseOptions }); + const replacement = createOrganizationRuntimeControlHostWithCoreForTest(config, replacementCore, { acceptanceStorePath: root, controlToken: token, storeOptions: leaseOptions }); + try { + await Promise.all([first.start(), replacement.start()]); + await first.accept(request("head")); + await firstCore.waitForWakes(1); + await replacement.accept(request("head")); + await replacement.accept(request("later")); + await firstCore.waitForWakes(1); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(replacementCore.wakes.length, 0); + firstCore.release(); + await replacementCore.waitForWakes(1); + assert.equal(replacementCore.wakes[0]?.event.id, "later"); + replacementCore.release(); + await Promise.all([first.stop(), replacement.stop()]); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("held head automatically retries at claim expiry with its stable wake id", async () => { + const root = await privateRoot(); + try { + const setup = await WakeAcceptanceStore.open(root, { ...testStoreOptions, claimTtlMs: 250 }); + const head = await setup.accept(parseWakeAcceptanceRequest(request("post-engine-pre-receipt"))); + await setup.accept(parseWakeAcceptanceRequest(request("blocked-later"))); + const claim = await setup.acquireClaim(head.record.acceptance_id, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + if (claim.state !== "acquired") throw new Error("claim missing"); + await setup.transitionClaimed(head.record.acceptance_id, claim.claim, "running"); + await setup.close(); + const core = new FakeCoreHost(); + const replacement = createOrganizationRuntimeControlHostWithCoreForTest(config, core, { acceptanceStorePath: root, controlToken: token, storeOptions: { ...testStoreOptions, claimTtlMs: 250 } }); + await replacement.start(); + await waitFor(() => core.wakes.length === 1); + assert.equal(core.wakes[0]?.event.id, "post-engine-pre-receipt"); + core.release(); + await waitFor(() => core.wakes.length === 2); + assert.equal(core.wakes[1]?.event.id, "blocked-later"); + core.release(); await replacement.stop(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("terminal compaction keeps the declared 2048-receipt delivery-idempotency horizon", async () => { + const root = await privateRoot(); + try { + let firstId = ""; + await Promise.all(Array.from({ length: 2_112 }, async (_, index) => { + const parsed = parseWakeAcceptanceRequest(request(`terminal-${index}`)); + const acceptanceId = randomUUID(); if (index === 0) firstId = acceptanceId; + const timestamp = new Date(index).toISOString(); + const record = { acceptance_id: acceptanceId, agent_id: parsed.agent_id, delivery_id: parsed.delivery_id, request_digest: wakeAcceptanceDigest(parsed), event: parsed.event, state: "completed", accepted_at: timestamp, updated_at: timestamp }; + const file = `${createHash("sha256").update(`${parsed.agent_id}\u0000${parsed.delivery_id}`).digest("hex")}.json`; + await writeFile(path.join(root, file), JSON.stringify(record), { mode: 0o600 }); + })); + const store = await WakeAcceptanceStore.open(root, testStoreOptions); + const latest = await store.accept(parseWakeAcceptanceRequest(request("terminal-2112"))); + assert.equal(await store.status(firstId), undefined); + assert.equal((await store.status(latest.record.acceptance_id))?.state, "accepted"); + assert.ok((await store.activity()).length >= TERMINAL_RECEIPT_IDEMPOTENCY_HORIZON); + await store.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + test("stale running claims recover with a new fence and reject an old terminal write", async () => { const root = await privateRoot(); try { @@ -109,10 +251,29 @@ test("stale running claims recover with a new fence and reject an old terminal w } finally { await rm(root, { recursive: true, force: true }); } }); +test("active work renews its durable claim beyond the original lease", async () => { + const root = await privateRoot(); + try { + const store = await WakeAcceptanceStore.open(root, { ...testStoreOptions, claimTtlMs: 5_000 }); + const accepted = await store.accept(parseWakeAcceptanceRequest(request("long-running"))); + const claimed = await store.acquireClaim(accepted.record.acceptance_id, "66666666-6666-4666-8666-666666666666"); + if (claimed.state !== "acquired") throw new Error("claim missing"); + await store.transitionClaimed(accepted.record.acceptance_id, claimed.claim, "running"); + await new Promise((resolve) => setTimeout(resolve, 100)); + const renewed = await store.renewClaim(accepted.record.acceptance_id, claimed.claim); + await new Promise((resolve) => setTimeout(resolve, 950)); + assert.equal((await store.acquireClaim(accepted.record.acceptance_id, "77777777-7777-4777-8777-777777777777")).state, "held"); + await store.transitionClaimed(accepted.record.acceptance_id, renewed, "completed"); + await store.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + test("a takeover between claim check and replacement fences the old writer", async () => { const root = await privateRoot(); try { - const setup = await WakeAcceptanceStore.open(root, { ...testStoreOptions, claimTtlMs: 200 }); + let now = 0; + const clock = { ...testStoreOptions, claimTtlMs: 200, nowForTest: () => now }; + const setup = await WakeAcceptanceStore.open(root, clock); const accepted = await setup.accept(parseWakeAcceptanceRequest(request("interleaving"))); const first = await setup.acquireClaim(accepted.record.acceptance_id, "33333333-3333-4333-8333-333333333333"); if (first.state !== "acquired") throw new Error("initial claim missing"); @@ -125,11 +286,11 @@ test("a takeover between claim check and replacement fences the old writer", asy const paused = new Promise((resolve) => { resume = resolve; }); let firstOwnerLive = true; const ownerLiveness = async (lock: { readonly owner_id: string }): Promise => lock.owner_id === first.claim.owner_id && firstOwnerLive; - const old = await WakeAcceptanceStore.open(root, { ...testStoreOptions, claimTtlMs: 200, afterFinalLockAssertion: async () => { checked(); await paused; }, ownerLiveness }); - const replacement = await WakeAcceptanceStore.open(root, { ...testStoreOptions, claimTtlMs: 200, ownerLiveness }); + const old = await WakeAcceptanceStore.open(root, { ...clock, afterFinalLockAssertion: async () => { checked(); await paused; }, ownerLiveness }); + const replacement = await WakeAcceptanceStore.open(root, { ...clock, ownerLiveness }); const oldTerminal = old.transitionClaimed(accepted.record.acceptance_id, first.claim, "completed"); await reached; - await new Promise((resolve) => setTimeout(resolve, 250)); + now = 250; assert.equal((await replacement.acquireClaim(accepted.record.acceptance_id, "44444444-4444-4444-8444-444444444444")).state, "held"); firstOwnerLive = false; const second = await replacement.acquireClaim(accepted.record.acceptance_id, "44444444-4444-4444-8444-444444444444"); @@ -187,21 +348,27 @@ test("production transition locking fails closed outside Linux", async (context) class FakeCoreHost implements Pick { readonly wakes: OrganizationRuntimeWakeRequest[] = []; private releaseTurn: (() => void) | undefined; + private readonly wakeWaiters: Array<{ count: number; resolve: () => void }> = []; async start(): Promise {} async wake(request_: OrganizationRuntimeWakeRequest) { this.wakes.push(request_); + for (const waiter of this.wakeWaiters.splice(0)) { + if (this.wakes.length >= waiter.count) waiter.resolve(); + else this.wakeWaiters.push(waiter); + } await new Promise((resolve) => { this.releaseTurn = resolve; }); return { version: "noopolis.daimon.wake-result.v1", status: "completed", agentId: request_.agentId, wakeId: request_.event.id, text: "private", durationMs: 1 } as const; } - async health(_agentId?: string) { return { version: "noopolis.daimon.organization-runtime-health.v1" as const, state: "running" as const, agents: [{ agentId: "alpha", state: "idle" as const }] }; } + async health(_agentId?: string) { return { version: "noopolis.daimon.organization-runtime-health.v1" as const, state: "running" as const, agents: [{ agentId: "alpha", engine: "codex" as const, state: "idle" as const }] }; } async activity() { return { version: "noopolis.daimon.organization-runtime-activity.v1" as const, items: [] }; } async stop() { this.release(); return { version: "noopolis.daimon.organization-runtime-stop.v1" as const, state: "stopped" as const }; } + async waitForWakes(count: number): Promise { if (this.wakes.length >= count) return; await new Promise((resolve) => this.wakeWaiters.push({ count, resolve })); } release(): void { this.releaseTurn?.(); } } async function privateRoot(): Promise { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-acceptance-")); await chmod(root, 0o700); return root; } -async function waitFor(predicate: () => boolean | Promise): Promise { - const deadline = Date.now() + 5_000; +async function waitFor(predicate: () => boolean | Promise, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; do { if (await predicate()) return; await new Promise((resolve) => setTimeout(resolve, 10)); diff --git a/src/runtime/wakeAcceptanceRecord.ts b/src/runtime/wakeAcceptanceRecord.ts new file mode 100644 index 0000000..14e8834 --- /dev/null +++ b/src/runtime/wakeAcceptanceRecord.ts @@ -0,0 +1,47 @@ +import { + WAKE_ACCEPTANCE_VERSION, + WAKE_RECEIPT_STATUS_VERSION, + parseWakeAcceptanceRequest, + sanitizeWakeCompletionText, + type OrganizationRuntimeWakeAcceptance, + type OrganizationRuntimeWakeAcceptanceRequest, + type OrganizationRuntimeWakeReceiptStatus, + type WakeReceiptCode, + type WakeReceiptState, + wakeAcceptanceDigest +} from "./wakeAcceptanceTypes.js"; + +export type StoredWakeAcceptanceRecord = Readonly<{ + acceptance_id: string; agent_id: string; delivery_id: string; request_digest: string; + event: OrganizationRuntimeWakeAcceptanceRequest["event"]; state: WakeReceiptState; + accepted_at: string; updated_at: string; claim_generation?: string; code?: WakeReceiptCode; text?: string; +}>; + +export function publicAcceptance(record: StoredWakeAcceptanceRecord): OrganizationRuntimeWakeAcceptance { + return { version: WAKE_ACCEPTANCE_VERSION, acceptance_id: record.acceptance_id, agent_id: record.agent_id, delivery_id: record.delivery_id, request_digest: record.request_digest, state: "accepted", accepted_at: record.accepted_at }; +} +export function publicStatus(record: StoredWakeAcceptanceRecord): OrganizationRuntimeWakeReceiptStatus { + return { version: WAKE_RECEIPT_STATUS_VERSION, acceptance_id: record.acceptance_id, agent_id: record.agent_id, delivery_id: record.delivery_id, request_digest: record.request_digest, state: record.state, accepted_at: record.accepted_at, updated_at: record.updated_at, ...(record.code === undefined ? {} : { code: record.code }), ...(record.text === undefined ? {} : { text: record.text }) }; +} +export function parseStoredWakeAcceptance(value: unknown): StoredWakeAcceptanceRecord { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("wake acceptance record is invalid"); + const record = value as Record; + const keys = ["acceptance_id", "agent_id", "delivery_id", "request_digest", "event", "state", "accepted_at", "updated_at", "claim_generation", "code", "text"]; + if (Object.keys(record).some((key) => !keys.includes(key))) throw new Error("wake acceptance record is invalid"); + const parsed = parseWakeAcceptanceRequest({ token: undefined, agent_id: string(record.agent_id), delivery_id: string(record.delivery_id), event: record.event }); + const state = string(record.state) as WakeReceiptState; + if (!(["accepted", "running", "completed", "failed", "stopped"] as const).includes(state)) throw new Error("wake acceptance record is invalid"); + const code = record.code === undefined ? undefined : string(record.code) as WakeReceiptCode; + const claimGeneration = record.claim_generation === undefined ? undefined : string(record.claim_generation); + const completionText = record.text === undefined ? undefined : sanitizeWakeCompletionText(string(record.text)); + if (claimGeneration !== undefined && !uuid(claimGeneration)) throw new Error("wake acceptance record is invalid"); + if (code !== undefined && !(["engine_failed", "host_stopped", "host_stopping", "queue_full", "unknown_agent"] as const).includes(code)) throw new Error("wake acceptance record is invalid"); + if ((state === "accepted" || state === "running" || state === "completed") && code !== undefined) throw new Error("wake acceptance record is invalid"); + if ((state === "failed" || state === "stopped") && code === undefined) throw new Error("wake acceptance record is invalid"); + if ((state !== "completed" && completionText !== undefined) || completionText !== record.text) throw new Error("wake acceptance record is invalid"); + if (string(record.request_digest) !== wakeAcceptanceDigest(parsed) || !uuid(string(record.acceptance_id))) throw new Error("wake acceptance record is invalid"); + return { acceptance_id: string(record.acceptance_id), agent_id: parsed.agent_id, delivery_id: parsed.delivery_id, request_digest: string(record.request_digest), event: parsed.event, state, accepted_at: timestamp(record.accepted_at), updated_at: timestamp(record.updated_at), ...(claimGeneration === undefined ? {} : { claim_generation: claimGeneration }), ...(code === undefined ? {} : { code }), ...(completionText === undefined ? {} : { text: completionText }) }; +} +function string(value: unknown): string { if (typeof value !== "string") throw new Error("wake acceptance record is invalid"); return value; } +function timestamp(value: unknown): string { const result = string(value); if (Number.isNaN(Date.parse(result)) || new Date(result).toISOString() !== result) throw new Error("wake acceptance record is invalid"); return result; } +function uuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value); } diff --git a/src/runtime/wakeAcceptanceRetention.ts b/src/runtime/wakeAcceptanceRetention.ts new file mode 100644 index 0000000..8d82a0f --- /dev/null +++ b/src/runtime/wakeAcceptanceRetention.ts @@ -0,0 +1,19 @@ +export const TERMINAL_RECEIPT_IDEMPOTENCY_HORIZON = 2_048; +export const WAKE_ACCEPTANCE_COMPACTION_THRESHOLD = 2_112; +export const MAX_WAKE_ACCEPTANCE_RECORDS = 2_176; + +export type RetentionCandidate = Readonly<{ + file: string; + state: "accepted" | "running" | "completed" | "failed" | "stopped"; + updatedAt: string; + acceptanceId: string; +}>; + +/** Active work is never eligible; the newest terminal receipt horizon survives. */ +export function terminalFilesToCompact(records: readonly RetentionCandidate[]): readonly string[] { + if (records.length < WAKE_ACCEPTANCE_COMPACTION_THRESHOLD) return []; + const terminal = records + .filter((record) => record.state === "completed" || record.state === "failed" || record.state === "stopped") + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.acceptanceId.localeCompare(left.acceptanceId)); + return terminal.slice(TERMINAL_RECEIPT_IDEMPOTENCY_HORIZON).map((record) => record.file); +} diff --git a/src/runtime/wakeAcceptanceStore.ts b/src/runtime/wakeAcceptanceStore.ts index 8892cb9..6ab9a5d 100644 --- a/src/runtime/wakeAcceptanceStore.ts +++ b/src/runtime/wakeAcceptanceStore.ts @@ -5,36 +5,25 @@ import { createHash, randomUUID } from "node:crypto"; import { MAX_WAKE_ACCEPTANCE_RECORD_BYTES, - WAKE_ACCEPTANCE_VERSION, - WAKE_RECEIPT_STATUS_VERSION, parseWakeAcceptanceRequest, - type OrganizationRuntimeWakeAcceptance, + sanitizeWakeCompletionText, type OrganizationRuntimeWakeAcceptanceRequest, type OrganizationRuntimeWakeReceiptStatus, type WakeReceiptCode, type WakeReceiptState, wakeAcceptanceDigest } from "./wakeAcceptanceTypes.js"; +import { parseStoredWakeAcceptance, publicAcceptance, publicStatus, type StoredWakeAcceptanceRecord } from "./wakeAcceptanceRecord.js"; import { assertOfflineReconciliationLeaseAvailable } from "./wakeAcceptanceReconciliation.js"; import { acquireHostRegistration, releaseHostRegistration, type StoreHostRegistration } from "./storeCoordination.js"; +import { MAX_WAKE_ACCEPTANCE_RECORDS, terminalFilesToCompact } from "./wakeAcceptanceRetention.js"; -type Stored = Readonly<{ - acceptance_id: string; - agent_id: string; - delivery_id: string; - request_digest: string; - event: OrganizationRuntimeWakeAcceptanceRequest["event"]; - state: WakeReceiptState; - accepted_at: string; - updated_at: string; - claim_generation?: string; - code?: WakeReceiptCode; -}>; -export type WakeExecutionClaim = Readonly<{ owner_id: string; generation: string; expires_at: string }>; -export type WakeExecutionClaimResult = Readonly<{ state: "acquired"; claim: WakeExecutionClaim }> | Readonly<{ state: "held" | "terminal" }>; +type Stored = StoredWakeAcceptanceRecord; +export type WakeExecutionClaim = Readonly<{ acceptance_id: string; owner_id: string; generation: string; expires_at: string }>; +export type WakeExecutionClaimResult = Readonly<{ state: "acquired"; claim: WakeExecutionClaim }> | Readonly<{ state: "held"; retry_at: string }> | Readonly<{ state: "terminal" }>; type TransitionLock = Readonly<{ owner_id: string; generation: string; pid: number; process_start: string; boot_id: string; pid_namespace_dev: number; pid_namespace_ino: number }>; type DirectoryIdentity = Readonly<{ dev: number; ino: number; uid: number; mode: number }>; -export type WakeAcceptanceStoreTestOptions = Readonly<{ claimTtlMs?: number; afterFinalLockAssertion?: () => Promise; ownerLiveness?: (lock: TransitionLock) => Promise; processIdentity?: () => Promise> }>; +export type WakeAcceptanceStoreTestOptions = Readonly<{ claimTtlMs?: number; afterFinalLockAssertion?: () => Promise; nowForTest?: () => number; ownerLiveness?: (lock: TransitionLock) => Promise; processIdentity?: () => Promise> }>; /** Deliberately absent from the public option type; adjacent tests synchronize only this race. */ type InternalTestHooks = Readonly<{ afterInitialLeaseCheckForTest?: () => Promise }>; const DEFAULT_CLAIM_TTL_MS = 240_000; @@ -42,7 +31,8 @@ const DEFAULT_CLAIM_TTL_MS = 240_000; /** Durable, private idempotency authority; callers must pre-create its 0700 root. */ export class WakeAcceptanceStore { private mutations: Promise = Promise.resolve(); - private constructor(private readonly root: string, private readonly directory: Awaited>, private readonly identity: DirectoryIdentity, private readonly registration: StoreHostRegistration, private readonly claimTtlMs: number, private readonly owner: Omit, private readonly ownerLiveness: (lock: TransitionLock) => Promise, private readonly afterFinalLockAssertion?: () => Promise) {} + private readonly acceptanceFiles = new Map(); + private constructor(private readonly root: string, private readonly directory: Awaited>, private readonly identity: DirectoryIdentity, private readonly registration: StoreHostRegistration, private readonly claimTtlMs: number, private readonly now: () => number, private readonly owner: Omit, private readonly ownerLiveness: (lock: TransitionLock) => Promise, private readonly afterFinalLockAssertion?: () => Promise) {} static async open(root: string, options: WakeAcceptanceStoreTestOptions = {}): Promise { const claimTtlMs = options.claimTtlMs ?? DEFAULT_CLAIM_TTL_MS; @@ -60,7 +50,7 @@ export class WakeAcceptanceStore { const real = await realpath(root); if (!same(identity(before), identity(after))) throw new Error("wake acceptance store changed during validation"); const registration = await acquireHostRegistration(real, directory, owner); - return new WakeAcceptanceStore(real, directory, identity(before), registration, claimTtlMs, owner, options.ownerLiveness ?? processIsAlive, options.afterFinalLockAssertion); + return new WakeAcceptanceStore(real, directory, identity(before), registration, claimTtlMs, options.nowForTest ?? Date.now, owner, options.ownerLiveness ?? processIsAlive, options.afterFinalLockAssertion); } catch (error) { await directory.close().catch(() => undefined); throw error; @@ -80,6 +70,8 @@ export class WakeAcceptanceStore { if (existing.request_digest !== request_digest) throw new WakeAcceptanceConflictError(); return { record: existing, created: false }; } + await this.compactTerminalRecords(); + if ((await this.files()).length >= MAX_WAKE_ACCEPTANCE_RECORDS) throw new Error("wake acceptance store has no capacity without deleting active work"); const now = new Date().toISOString(); const record: Stored = { acceptance_id: randomUUID(), agent_id: request.agent_id, delivery_id: request.delivery_id, request_digest, event: request.event, state: "accepted", accepted_at: now, updated_at: now }; const temporary = path.join(this.root, `.pending-${randomUUID()}`); @@ -92,6 +84,7 @@ export class WakeAcceptanceStore { return { record: winner, created: false }; } await this.directory.sync(); + this.acceptanceFiles.set(record.acceptance_id, target); return { record, created: true }; } finally { await unlink(temporary).catch(() => undefined); @@ -103,6 +96,17 @@ export class WakeAcceptanceStore { return record === undefined ? undefined : publicStatus(record); } + async activity(): Promise)[]> { + const records = await Promise.all((await this.files()).map(async (file) => await this.read(path.join(this.root, file)))); + records.sort((left, right) => left.accepted_at.localeCompare(right.accepted_at) || left.acceptance_id.localeCompare(right.acceptance_id)); + const queued = new Map(); + return records.map((record) => { + const position = record.state === "accepted" ? (queued.get(record.agent_id) ?? 0) + 1 : undefined; + if (position !== undefined) queued.set(record.agent_id, position); + return { ...publicStatus(record), active: record.state === "running", ...(position === undefined ? {} : { queue_position: position }) }; + }); + } + async recoverable(agentIds: ReadonlySet): Promise { const result: Stored[] = []; for (const file of await this.files()) { @@ -110,7 +114,7 @@ export class WakeAcceptanceStore { if (!agentIds.has(record.agent_id)) throw new Error("wake acceptance store contains an unknown agent authority"); if (record.state === "accepted" || record.state === "running") result.push(record); } - return result; + return result.sort((left, right) => left.accepted_at.localeCompare(right.accepted_at) || left.acceptance_id.localeCompare(right.acceptance_id)); } transition(acceptanceId: string, state: WakeReceiptState, code?: WakeReceiptCode): Promise { @@ -125,18 +129,18 @@ export class WakeAcceptanceStore { if (isTerminal(record.state)) return { state: "terminal" }; const target = this.claimFor(record); const current = await this.readClaimOptional(target); - if (current !== undefined && !expired(current)) return { state: "held" }; + if (current !== undefined && !expired(current, this.now())) return { state: "held", retry_at: current.expires_at }; const lock = await this.acquireTransitionLock(record, ownerId); - if (lock === undefined) return { state: "held" }; + if (lock === undefined) return { state: "held", retry_at: new Date(this.now() + 50).toISOString() }; try { const checked = await this.readClaimOptional(target); - if (checked !== undefined && !expired(checked)) return { state: "held" }; + if (checked !== undefined && !expired(checked, this.now())) return { state: "held", retry_at: checked.expires_at }; if (checked !== undefined) { await unlink(target); await this.directory.sync(); } - const claim: WakeExecutionClaim = { owner_id: ownerId, generation: randomUUID(), expires_at: new Date(Date.now() + this.claimTtlMs).toISOString() }; + const claim: WakeExecutionClaim = { acceptance_id: record.acceptance_id, owner_id: ownerId, generation: randomUUID(), expires_at: new Date(this.now() + this.claimTtlMs).toISOString() }; const temporary = path.join(this.root, `.claim-${randomUUID()}`); try { await this.writeNew(temporary, claim); - try { await link(temporary, target); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return { state: "held" }; throw error; } + try { await link(temporary, target); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") { const winner = await this.readClaim(target); return { state: "held", retry_at: winner.expires_at }; } throw error; } await this.directory.sync(); return { state: "acquired", claim }; } finally { await unlink(temporary).catch(() => undefined); } @@ -144,8 +148,27 @@ export class WakeAcceptanceStore { }); } - transitionClaimed(acceptanceId: string, claim: WakeExecutionClaim, state: WakeReceiptState, code?: WakeReceiptCode): Promise { + claimHeartbeatIntervalMs(): number { return Math.max(1, Math.floor(this.claimTtlMs / 3)); } + + renewClaim(acceptanceId: string, claim: WakeExecutionClaim): Promise { return this.serialize(async () => { + const record = await this.findByAcceptanceId(acceptanceId); + if (record === undefined) throw new Error("wake acceptance receipt is unavailable"); + const lock = await this.acquireTransitionLock(record, claim.owner_id); + if (lock === undefined) throw new WakeExecutionClaimLostError(); + try { + const current = await this.readClaim(this.claimFor(record)); + if (current.acceptance_id !== acceptanceId || current.acceptance_id !== claim.acceptance_id || current.owner_id !== claim.owner_id || current.generation !== claim.generation || current.expires_at !== claim.expires_at || expired(current, this.now())) throw new WakeExecutionClaimLostError(); + const renewed: WakeExecutionClaim = { ...current, expires_at: new Date(this.now() + this.claimTtlMs).toISOString() }; + await this.replaceValue(this.claimFor(record), renewed); + return renewed; + } finally { await this.releaseTransitionLock(record, lock); } + }); + } + + transitionClaimed(acceptanceId: string, claim: WakeExecutionClaim, state: WakeReceiptState, code?: WakeReceiptCode, completedText?: string): Promise { + return this.serialize(async () => { + if (completedText !== undefined && state !== "completed") throw new Error("wake completion text requires completed state"); const initial = await this.findByAcceptanceId(acceptanceId); if (initial === undefined) throw new Error("wake acceptance receipt is unavailable"); const lock = await this.acquireTransitionLock(initial, claim.owner_id); @@ -162,8 +185,12 @@ export class WakeAcceptanceStore { await this.afterFinalLockAssertion?.(); await this.assertTransitionLock(record, lock); const target = this.fileFor(record.agent_id, record.delivery_id); - const next: Stored = { ...record, state, updated_at: new Date().toISOString(), claim_generation: claim.generation, ...(code === undefined ? {} : { code }) }; + const next: Stored = { ...record, state, updated_at: new Date().toISOString(), claim_generation: claim.generation, ...(code === undefined ? {} : { code }), ...(completedText === undefined ? {} : { text: sanitizeWakeCompletionText(completedText) }) }; await this.replace(target, next); + if (isTerminal(state)) { + const currentClaim = await this.readClaimOptional(this.claimFor(record)); + if (currentClaim?.owner_id === claim.owner_id && currentClaim.generation === claim.generation) { await unlink(this.claimFor(record)); await this.directory.sync(); } + } return next; } finally { await this.releaseTransitionLock(initial, lock); } }); @@ -198,8 +225,11 @@ export class WakeAcceptanceStore { private async findByAcceptanceId(acceptanceId: string): Promise { if (!uuid(acceptanceId)) return undefined; + const known = this.acceptanceFiles.get(acceptanceId); + if (known !== undefined) return await this.readOptional(known); for (const file of await this.files()) { const record = await this.read(path.join(this.root, file)); + this.acceptanceFiles.set(record.acceptance_id, path.join(this.root, file)); if (record.acceptance_id === acceptanceId) return record; } return undefined; @@ -209,7 +239,7 @@ export class WakeAcceptanceStore { if (record === undefined) throw new Error("wake acceptance receipt is unavailable"); return this.fileFor(record.agent_id, record.delivery_id); } - private claimFor(record: Stored): string { return this.fileFor(record.agent_id, record.delivery_id).replace(/\.json$/u, ".claim"); } + private claimFor(record: Stored): string { return path.join(this.root, `${createHash("sha256").update(record.agent_id).digest("hex")}.agent-claim`); } private lockFor(record: Stored): string { return this.fileFor(record.agent_id, record.delivery_id).replace(/\.json$/u, ".transition-lock"); } private async acquireTransitionLock(record: Stored, ownerId: string): Promise { const target = this.lockFor(record); @@ -242,17 +272,17 @@ export class WakeAcceptanceStore { await this.verify(); const entries = await readdir(this.root); const files = entries.filter((entry) => /^[a-f0-9]{64}\.json$/.test(entry)); - if (files.length > 2_112 || entries.length > 2_176) throw new Error("wake acceptance store exceeds its bounded record limit"); + if (files.length > MAX_WAKE_ACCEPTANCE_RECORDS || entries.length > MAX_WAKE_ACCEPTANCE_RECORDS + 128) throw new Error("wake acceptance store exceeds its bounded record limit"); return files; } - private async claimFiles(): Promise { return (await readdir(this.root)).filter((entry) => /^[a-f0-9]{64}\.claim$/.test(entry)); } + private async claimFiles(): Promise { return (await readdir(this.root)).filter((entry) => /^[a-f0-9]{64}\.agent-claim$/.test(entry)); } private async readOptional(file: string): Promise { try { return await this.read(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } private async read(file: string): Promise { const entry = await lstat(file); if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 0o777) !== 0o600 || entry.size > MAX_WAKE_ACCEPTANCE_RECORD_BYTES) throw new Error("wake acceptance record is unsafe"); const bytes = await readFile(file); if (bytes.length > MAX_WAKE_ACCEPTANCE_RECORD_BYTES) throw new Error("wake acceptance record exceeds its bound"); - return parseStored(JSON.parse(bytes.toString("utf8"))); + return parseStoredWakeAcceptance(JSON.parse(bytes.toString("utf8"))); } private async readClaimOptional(file: string): Promise { try { return await this.readClaim(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } private async readClaim(file: string): Promise { @@ -261,9 +291,9 @@ export class WakeAcceptanceStore { const value: unknown = JSON.parse((await readFile(file)).toString("utf8")); if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("wake execution claim is invalid"); const record = value as Record; - if (Object.keys(record).length !== 3 || !Object.hasOwn(record, "owner_id") || !Object.hasOwn(record, "generation") || !Object.hasOwn(record, "expires_at")) throw new Error("wake execution claim is invalid"); - const claim = { owner_id: string(record.owner_id), generation: string(record.generation), expires_at: timestamp(record.expires_at) }; - if (!uuid(claim.owner_id) || !uuid(claim.generation)) throw new Error("wake execution claim is invalid"); + if (Object.keys(record).length !== 4 || !Object.hasOwn(record, "acceptance_id") || !Object.hasOwn(record, "owner_id") || !Object.hasOwn(record, "generation") || !Object.hasOwn(record, "expires_at")) throw new Error("wake execution claim is invalid"); + const claim = { acceptance_id: string(record.acceptance_id), owner_id: string(record.owner_id), generation: string(record.generation), expires_at: timestamp(record.expires_at) }; + if (!uuid(claim.acceptance_id) || !uuid(claim.owner_id) || !uuid(claim.generation)) throw new Error("wake execution claim is invalid"); return claim; } private async readLockOptional(file: string): Promise { try { return await this.readLock(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } @@ -282,14 +312,26 @@ export class WakeAcceptanceStore { const record = await this.findByAcceptanceId(acceptanceId); if (record === undefined) throw new Error("wake acceptance receipt is unavailable"); const current = await this.readClaim(this.claimFor(record)); - if (current.owner_id !== claim.owner_id || current.generation !== claim.generation || current.expires_at !== claim.expires_at || expired(current)) throw new WakeExecutionClaimLostError(); + if (current.acceptance_id !== acceptanceId || current.acceptance_id !== claim.acceptance_id || current.owner_id !== claim.owner_id || current.generation !== claim.generation || current.expires_at !== claim.expires_at || expired(current, this.now())) throw new WakeExecutionClaimLostError(); return record; } + private async compactTerminalRecords(): Promise { + const files = await this.files(); + if (files.length < 2_112) return; + const records = await Promise.all(files.map(async (file) => ({ file, record: await this.read(path.join(this.root, file)) }))); + for (const file of terminalFilesToCompact(records.map(({ file, record }) => ({ file, state: record.state, updatedAt: record.updated_at, acceptanceId: record.acceptance_id })))) { + const record = records.find((candidate) => candidate.file === file)?.record; + if (record !== undefined) this.acceptanceFiles.delete(record.acceptance_id); + await unlink(path.join(this.root, file)); + } + await this.directory.sync(); + } private async writeNew(file: string, record: unknown): Promise { const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow(), 0o600); try { await handle.writeFile(JSON.stringify(record)); await handle.sync(); } finally { await handle.close(); } } - private async replace(target: string, record: Stored): Promise { + private async replace(target: string, record: Stored): Promise { await this.replaceValue(target, record); } + private async replaceValue(target: string, record: unknown): Promise { const temporary = path.join(this.root, `.replace-${randomUUID()}`); try { await this.writeNew(temporary, record); await rename(temporary, target); await this.directory.sync(); } finally { await unlink(temporary).catch(() => undefined); } } @@ -305,33 +347,13 @@ export class WakeAcceptanceConflictError extends Error { constructor() { super(" export class WakeExecutionClaimLostError extends Error { constructor() { super("wake execution claim was lost"); } } /** A container boundary requires deployment-authorized offline reconciliation. */ export class WakeTransitionLockBlockedError extends Error { readonly code = "offline_reconciliation_required" as const; constructor() { super("wake transition lock requires offline reconciliation"); } } -export function publicAcceptance(record: Stored): OrganizationRuntimeWakeAcceptance { return { version: WAKE_ACCEPTANCE_VERSION, acceptance_id: record.acceptance_id, agent_id: record.agent_id, delivery_id: record.delivery_id, request_digest: record.request_digest, state: "accepted", accepted_at: record.accepted_at }; } -function publicStatus(record: Stored): OrganizationRuntimeWakeReceiptStatus { return { version: WAKE_RECEIPT_STATUS_VERSION, acceptance_id: record.acceptance_id, agent_id: record.agent_id, delivery_id: record.delivery_id, request_digest: record.request_digest, state: record.state, accepted_at: record.accepted_at, updated_at: record.updated_at, ...(record.code === undefined ? {} : { code: record.code }) }; } -function parseStored(value: unknown): Stored { - if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("wake acceptance record is invalid"); - const record = value as Record; - const keys = ["acceptance_id", "agent_id", "delivery_id", "request_digest", "event", "state", "accepted_at", "updated_at", "claim_generation", "code"]; - if (Object.keys(record).some((key) => !keys.includes(key))) throw new Error("wake acceptance record is invalid"); - const event = record.event; - const request = { token: undefined, agent_id: string(record.agent_id), delivery_id: string(record.delivery_id), event }; - const parsed = parseWakeAcceptanceRequest(request); - const state = string(record.state) as WakeReceiptState; - if (!(["accepted", "running", "completed", "failed", "stopped"] as const).includes(state)) throw new Error("wake acceptance record is invalid"); - const code = record.code === undefined ? undefined : string(record.code) as WakeReceiptCode; - const claimGeneration = record.claim_generation === undefined ? undefined : string(record.claim_generation); - if (claimGeneration !== undefined && !uuid(claimGeneration)) throw new Error("wake acceptance record is invalid"); - if (code !== undefined && !(["engine_failed", "host_stopped", "host_stopping", "queue_full", "unknown_agent"] as const).includes(code)) throw new Error("wake acceptance record is invalid"); - if ((state === "accepted" || state === "running" || state === "completed") && code !== undefined) throw new Error("wake acceptance record is invalid"); - if ((state === "failed" || state === "stopped") && code === undefined) throw new Error("wake acceptance record is invalid"); - if (string(record.request_digest) !== wakeAcceptanceDigest(parsed) || !uuid(string(record.acceptance_id))) throw new Error("wake acceptance record is invalid"); - return { acceptance_id: string(record.acceptance_id), agent_id: parsed.agent_id, delivery_id: parsed.delivery_id, request_digest: string(record.request_digest), event: parsed.event, state, accepted_at: timestamp(record.accepted_at), updated_at: timestamp(record.updated_at), ...(claimGeneration === undefined ? {} : { claim_generation: claimGeneration }), ...(code === undefined ? {} : { code }) }; -} +export { publicAcceptance } from "./wakeAcceptanceRecord.js"; function string(value: unknown): string { if (typeof value !== "string") throw new Error("wake acceptance record is invalid"); return value; } function integer(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new Error("wake transition lock is invalid"); return value; } function timestamp(value: unknown): string { const result = string(value); if (Number.isNaN(Date.parse(result)) || new Date(result).toISOString() !== result) throw new Error("wake acceptance record is invalid"); return result; } function uuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value); } function isTerminal(state: WakeReceiptState): boolean { return state === "completed" || state === "failed" || state === "stopped"; } -function expired(claim: WakeExecutionClaim): boolean { return Date.parse(claim.expires_at) <= Date.now(); } +function expired(claim: WakeExecutionClaim, now: number): boolean { return Date.parse(claim.expires_at) <= now; } function identity(value: Awaited>): DirectoryIdentity { const numeric = value as typeof value & { dev: number; ino: number; uid: number; mode: number }; return { dev: numeric.dev, ino: numeric.ino, uid: numeric.uid, mode: numeric.mode & 0o7777 }; diff --git a/src/runtime/wakeAcceptanceTypes.ts b/src/runtime/wakeAcceptanceTypes.ts index 38cdc26..bcee584 100644 --- a/src/runtime/wakeAcceptanceTypes.ts +++ b/src/runtime/wakeAcceptanceTypes.ts @@ -1,10 +1,15 @@ import { createHash } from "node:crypto"; +import { ORGANIZATION_RUNTIME_ACTIVITY_V2_VERSION } from "../contracts/runtimeContractManifest.js"; +import { redactCredentialText } from "../core/credentialRedaction.js"; + export const WAKE_ACCEPTANCE_VERSION = "noopolis.daimon.wake-acceptance.v2" as const; export const WAKE_RECEIPT_STATUS_VERSION = "noopolis.daimon.wake-receipt-status.v2" as const; export const WAKE_V2_VERSION = "noopolis.daimon.wake.v2" as const; +export const ACTIVITY_V2_VERSION = ORGANIZATION_RUNTIME_ACTIVITY_V2_VERSION; export const MAX_WAKE_ACCEPTANCE_BYTES = 16_384; export const MAX_WAKE_ACCEPTANCE_RECORD_BYTES = 65_536; +export const MAX_WAKE_COMPLETION_TEXT_BYTES = 16_384; /** Public HTTP body schema; bearer authentication is intentionally a header. */ export const WAKE_ACCEPTANCE_REQUEST_SCHEMA = { @@ -13,14 +18,14 @@ export const WAKE_ACCEPTANCE_REQUEST_SCHEMA = { agent_id: { type: "string", minLength: 1, maxLength: MAX_WAKE_ACCEPTANCE_BYTES, pattern: "\\S" }, delivery_id: { type: "string", minLength: 1, maxLength: MAX_WAKE_ACCEPTANCE_BYTES, pattern: "\\S" }, event: { type: "object", additionalProperties: false, required: ["version", "kind", "text", "occurred_at"], properties: { - version: { const: WAKE_V2_VERSION }, kind: { enum: ["manual", "message", "external"] }, text: { type: "string", maxLength: MAX_WAKE_ACCEPTANCE_BYTES }, occurred_at: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" } + version: { const: WAKE_V2_VERSION }, kind: { enum: ["manual", "message", "schedule", "external"] }, text: { type: "string", maxLength: MAX_WAKE_ACCEPTANCE_BYTES }, occurred_at: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" } } } } } as const; export const WAKE_RECEIPT_STATUS_SCHEMA = { $schema: "https://json-schema.org/draft/2020-12/schema", $id: WAKE_RECEIPT_STATUS_VERSION, type: "object", additionalProperties: false, required: ["version", "acceptance_id", "agent_id", "delivery_id", "request_digest", "state", "accepted_at", "updated_at"], properties: { - version: { const: WAKE_RECEIPT_STATUS_VERSION }, acceptance_id: { type: "string", pattern: "^[0-9a-f-]{36}$" }, agent_id: { type: "string" }, delivery_id: { type: "string" }, request_digest: { type: "string", pattern: "^[a-f0-9]{64}$" }, state: { enum: ["accepted", "running", "completed", "failed", "stopped"] }, accepted_at: { type: "string" }, updated_at: { type: "string" }, code: { enum: ["engine_failed", "host_stopped", "host_stopping", "queue_full", "unknown_agent"] } + version: { const: WAKE_RECEIPT_STATUS_VERSION }, acceptance_id: { type: "string", pattern: "^[0-9a-f-]{36}$" }, agent_id: { type: "string" }, delivery_id: { type: "string" }, request_digest: { type: "string", pattern: "^[a-f0-9]{64}$" }, state: { enum: ["accepted", "running", "completed", "failed", "stopped"] }, accepted_at: { type: "string" }, updated_at: { type: "string" }, code: { enum: ["engine_failed", "host_stopped", "host_stopping", "queue_full", "unknown_agent"] }, text: { type: "string", maxLength: MAX_WAKE_COMPLETION_TEXT_BYTES } } } as const; @@ -30,7 +35,7 @@ export type OrganizationRuntimeWakeAcceptanceRequest = Readonly<{ token: string | undefined; agent_id: string; delivery_id: string; - event: Readonly<{ version: typeof WAKE_V2_VERSION; kind: "manual" | "message" | "external"; text: string; occurred_at: string }>; + event: Readonly<{ version: typeof WAKE_V2_VERSION; kind: "manual" | "message" | "schedule" | "external"; text: string; occurred_at: string }>; }>; export type OrganizationRuntimeWakeAcceptance = Readonly<{ version: typeof WAKE_ACCEPTANCE_VERSION; @@ -51,6 +56,20 @@ export type OrganizationRuntimeWakeReceiptStatus = Readonly<{ accepted_at: string; updated_at: string; code?: WakeReceiptCode; + text?: string; +}>; + +/** Bounded authenticated reply payload; strips common credential shapes before persistence. */ +export function sanitizeWakeCompletionText(value: string): string { + return redactCredentialText(value, [], MAX_WAKE_COMPLETION_TEXT_BYTES); +} +export type OrganizationRuntimeActivityV2Item = OrganizationRuntimeWakeReceiptStatus & Readonly<{ + active: boolean; + queue_position?: number; +}>; +export type OrganizationRuntimeActivityV2 = Readonly<{ + version: typeof ACTIVITY_V2_VERSION; + items: readonly OrganizationRuntimeActivityV2Item[]; }>; export type OrganizationRuntimeWakeAcceptanceResult = OrganizationRuntimeWakeAcceptance | Readonly<{ version: typeof WAKE_ACCEPTANCE_VERSION; @@ -64,7 +83,7 @@ export function parseWakeAcceptanceRequest(value: unknown): OrganizationRuntimeW const event = record(root.event, "wake acceptance.event"); exact(event, ["version", "kind", "text", "occurred_at"], "wake acceptance.event"); const kind = text(event.kind, "wake acceptance.event.kind"); - if (kind !== "manual" && kind !== "message" && kind !== "external") throw new TypeError("wake acceptance.event.kind is not supported"); + if (kind !== "manual" && kind !== "message" && kind !== "schedule" && kind !== "external") throw new TypeError("wake acceptance.event.kind is not supported"); const body = text(event.text, "wake acceptance.event.text"); if (Buffer.byteLength(body, "utf8") > MAX_WAKE_ACCEPTANCE_BYTES) throw new TypeError("wake acceptance.event.text exceeds the wake text limit"); if (text(event.version, "wake acceptance.event.version") !== WAKE_V2_VERSION) throw new TypeError("wake acceptance.event.version is not supported"); From fad269d2f55be27a493e95836c604fc651050801 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:57 +0200 Subject: [PATCH 08/24] build(runtime): package native and explicit test runtimes --- Dockerfile.test-runtime | 8 + package.json | 5 +- scripts/verifyProductionClosure.mjs | 19 ++ src/runtime/fixtures/testMcpServer.mjs | 9 + src/runtime/testRuntimeMcpActions.ts | 52 +++++ src/runtime/testRuntimeMoltnetActions.test.ts | 33 ++++ src/runtime/testRuntimeMoltnetActions.ts | 66 +++++++ src/runtime/testRuntimeSubprocess.test.ts | 156 +++++++++++++++ src/runtime/testRuntimeSubprocess.ts | 180 ++++++++++++++++++ tsconfig.build.json | 6 +- tsconfig.test-runtime.json | 5 + 11 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 Dockerfile.test-runtime create mode 100644 scripts/verifyProductionClosure.mjs create mode 100644 src/runtime/fixtures/testMcpServer.mjs create mode 100644 src/runtime/testRuntimeMcpActions.ts create mode 100644 src/runtime/testRuntimeMoltnetActions.test.ts create mode 100644 src/runtime/testRuntimeMoltnetActions.ts create mode 100644 src/runtime/testRuntimeSubprocess.test.ts create mode 100644 src/runtime/testRuntimeSubprocess.ts create mode 100644 tsconfig.test-runtime.json diff --git a/Dockerfile.test-runtime b/Dockerfile.test-runtime new file mode 100644 index 0000000..e194061 --- /dev/null +++ b/Dockerfile.test-runtime @@ -0,0 +1,8 @@ +FROM node:22-bookworm-slim +WORKDIR /opt/noopolis/daimon-test +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force +COPY dist-test-runtime /opt/noopolis/daimon-test/dist +LABEL org.noopolis.daimon.explicit-test-runtime="true" +USER 65534:65534 +ENTRYPOINT ["/usr/local/bin/node", "/opt/noopolis/daimon-test/dist/runtime/testRuntimeSubprocess.js"] diff --git a/package.json b/package.json index 282e6e4..c868078 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,14 @@ "access": "public" }, "scripts": { - "build": "rm -rf dist && tsc --project tsconfig.build.json", + "build": "rm -rf dist && tsc --project tsconfig.build.json && node --import tsx scripts/emitRuntimeContractManifest.mjs && node src/runtime/native/copyArtifact.mjs", + "build:test-runtime": "rm -rf dist-test-runtime && tsc --project tsconfig.test-runtime.json", "clean": "rm -rf dist", "prepack": "npm run build", "prepublishOnly": "npm run typecheck && npm test", "typecheck": "tsc --project tsconfig.json --noEmit", + "verify:contract-manifest": "node --import tsx scripts/emitRuntimeContractManifest.mjs --check", + "verify:production-closure": "node scripts/verifyProductionClosure.mjs", "test": "node --import tsx --test \"src/**/*.test.ts\"", "emit-causal-fixture": "tsx src/observability/emitCausalFixture.ts", "emit-causal-fixture:spoof": "tsx src/observability/emitCausalFixture.ts --spoof", diff --git a/scripts/verifyProductionClosure.mjs b/scripts/verifyProductionClosure.mjs new file mode 100644 index 0000000..c9e476d --- /dev/null +++ b/scripts/verifyProductionClosure.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve("dist"); +const files = []; +async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) await walk(file); else files.push(file); + } +} +await walk(root); +assert.equal(files.some((file) => /testRuntime(?:Subprocess|MoltnetActions)/u.test(file)), false, "production dist contains explicit-test runtime modules"); +for (const file of files.filter((value) => /\.(?:js|d\.ts|map)$/u.test(value))) { + const bytes = await readFile(file, "utf8"); + assert.doesNotMatch(bytes, /DAIMON_EXPLICIT_TEST_RUNTIME|cognition_actions|mcp_config_path|testRuntimeSubprocess|testRuntimeMoltnetActions|testRuntimeMcpActions/u, `production closure reaches test runtime: ${file}`); +} +console.log(`production closure verified (${files.length} files)`); diff --git a/src/runtime/fixtures/testMcpServer.mjs b/src/runtime/fixtures/testMcpServer.mjs new file mode 100644 index 0000000..198968c --- /dev/null +++ b/src/runtime/fixtures/testMcpServer.mjs @@ -0,0 +1,9 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +const server = new Server({ name: "daimon-test-fixture", version: "1" }, { capabilities: { tools: {} } }); +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{ name: "checkpoint", description: "Records one bounded checkpoint", inputSchema: { type: "object", additionalProperties: false, required: ["phase"], properties: { phase: { type: "string" } } } }] })); +server.setRequestHandler(CallToolRequestSchema, async (request) => { await writeFile(path.join(process.env.HOME, "mcp-home-writable"), "ok"); return { content: [{ type: "text", text: `checkpoint:${request.params.arguments.phase}:home=${process.env.HOME}` }] }; }); +await server.connect(new StdioServerTransport()); diff --git a/src/runtime/testRuntimeMcpActions.ts b/src/runtime/testRuntimeMcpActions.ts new file mode 100644 index 0000000..abcd771 --- /dev/null +++ b/src/runtime/testRuntimeMcpActions.ts @@ -0,0 +1,52 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile } from "node:fs/promises"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const MAX_CONFIG_BYTES = 65_536; const MAX_ACTIONS = 16; const MAX_ARGUMENT_BYTES = 16_384; +type Server = Readonly<{ id: string; agent_id: string; command: string; args: string[]; tools: string[]; env_names: string[] }>; +type Trigger = Readonly<{ agent_id: string; wake_kind: "manual" | "message" | "schedule" | "external"; text_sha256: string; delivery_id?: string }>; +type Action = Readonly<{ type: "mcp_call"; trigger: Trigger; server_id: string; tool: string; arguments: Record }>; +export type ScriptedMcpReceipt = Readonly<{ type: "mcp_call"; delivery_id: string; server_id: string; tool: string; result_digest: string; is_error: boolean }>; + +export async function createScriptedMcpActions(value: unknown, configPath: unknown, receiptPath: unknown): Promise<(request: { agentId: string; event: { id: string; kind: string; text: string } }) => Promise> { + const actions = parseActions(value); if (actions.length === 0) return async () => []; + if (!absolute(configPath) || !absolute(receiptPath)) throw new Error("scripted MCP actions require absolute compiled artifact paths"); + const [configStat, receiptStat] = await Promise.all([lstat(configPath), lstat(receiptPath)]); if ([configStat, receiptStat].some((stat) => !stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CONFIG_BYTES)) throw new Error("scripted MCP artifact is unsafe"); + const configBytes = await readFile(configPath); const receipt = record(JSON.parse(await readFile(receiptPath, "utf8"))); const servers = parseConfig(JSON.parse(configBytes.toString("utf8"))); + if (receipt.version !== "spawnfile.explicit-test-mcp-receipt.v1" || receipt.artifact_sha256 !== `sha256:${createHash("sha256").update(configBytes).digest("hex")}`) throw new Error("scripted MCP artifact attestation mismatch"); + for (const action of actions) { const server = servers.get(action.server_id); if (!server || server.agent_id !== action.trigger.agent_id || !server.tools.includes(action.tool)) throw new Error("scripted MCP action is not declared"); } + return async (request) => { + const receipts: ScriptedMcpReceipt[] = []; + for (const action of actions.filter((candidate) => matches(candidate.trigger, request))) receipts.push(await call(servers.get(action.server_id)!, action, request.event.id)); + return receipts; + }; +} +async function call(server: Server, action: Action, deliveryId: string): Promise { + const env = Object.fromEntries(server.env_names.map((name) => { const value = process.env[name]; if (value === undefined) throw new Error(`scripted MCP environment ${name} is missing`); return [name, value]; })); + const transport = new StdioClientTransport({ command: server.command, args: server.args, env, stderr: "pipe" }); + const client = new Client({ name: "daimon-explicit-test-runtime", version: "1" }, { capabilities: {} }); + try { + await client.connect(transport); + const listed = await client.listTools(); if (!listed.tools.some((tool) => tool.name === action.tool) || listed.tools.some((tool) => !server.tools.includes(tool.name))) throw new Error("scripted MCP server tool declaration drift"); + const result = await client.callTool({ name: action.tool, arguments: action.arguments }, undefined, { timeout: 10_000 }); + const bytes = JSON.stringify(result); if (Buffer.byteLength(bytes) > 65_536) throw new Error("scripted MCP result is oversized"); + return { type: "mcp_call", delivery_id: deliveryId, server_id: action.server_id, tool: action.tool, result_digest: createHash("sha256").update(bytes).digest("hex"), is_error: result.isError === true }; + } finally { await client.close().catch(() => undefined); } +} +function parseActions(value: unknown): Action[] { + if (value === undefined) return []; if (!Array.isArray(value) || value.length > MAX_ACTIONS) throw new Error("invalid scripted cognition actions"); + return value.filter((item) => record(item).type === "mcp_call").map((item) => { const row = record(item); exact(row, ["type", "trigger", "server_id", "tool", "arguments"]); if (!identifier(row.server_id) || !identifier(row.tool)) throw new Error("invalid scripted MCP action"); const trigger = record(row.trigger); exactOptional(trigger, ["agent_id", "wake_kind", "text_sha256"], ["delivery_id"]); if (!identifier(trigger.agent_id) || !["manual", "message", "schedule", "external"].includes(trigger.wake_kind as string) || typeof trigger.text_sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(trigger.text_sha256) || (trigger.delivery_id !== undefined && (typeof trigger.delivery_id !== "string" || !/^(?:schedule|moltnet):\S{1,1024}$/u.test(trigger.delivery_id)))) throw new Error("invalid scripted MCP trigger"); const args = record(row.arguments); if (Buffer.byteLength(JSON.stringify(args)) > MAX_ARGUMENT_BYTES) throw new Error("scripted MCP arguments are oversized"); return row as unknown as Action; }); +} +function parseConfig(value: unknown): Map { + const root = record(value); exact(root, ["version", "compile_fingerprint", "servers"]); if (root.version !== "spawnfile.explicit-test-mcp.v1" || typeof root.compile_fingerprint !== "string" || !/^sf1:[a-f0-9]{12}$/u.test(root.compile_fingerprint) || !Array.isArray(root.servers) || root.servers.length > 8) throw new Error("invalid scripted MCP config"); + const servers = root.servers.map((value) => { const row = record(value); exact(row, ["id", "agent_id", "command", "args", "tools", "env_names"]); if (!identifier(row.id) || !identifier(row.agent_id) || !absolute(row.command) || !strings(row.args, 16, absolute) || !strings(row.tools, 16, identifier) || !strings(row.env_names, 16, identifier)) throw new Error("invalid scripted MCP server"); return row as unknown as Server; }); + if (new Set(servers.map((server) => server.id)).size !== servers.length) throw new Error("duplicate scripted MCP server"); return new Map(servers.map((server) => [server.id, server])); +} +function record(value: unknown): Record { if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid object"); return value as Record; } +function exact(value: Record, keys: string[]): void { if (Object.keys(value).sort().join("\0") !== [...keys].sort().join("\0")) throw new Error("unexpected field"); } +function exactOptional(value: Record, required: string[], optional: string[]): void { const keys = Object.keys(value); if (required.some((key) => !(key in value)) || keys.some((key) => !required.includes(key) && !optional.includes(key))) throw new Error("unexpected field"); } +function identifier(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$/u.test(value); } +function absolute(value: unknown): value is string { return typeof value === "string" && value.startsWith("/") && value.length <= 1024; } +function strings(value: unknown, limit: number, validate: (item: unknown) => boolean): value is string[] { return Array.isArray(value) && value.length <= limit && value.every(validate) && new Set(value).size === value.length; } +function matches(trigger: Trigger, request: { agentId: string; event: { id: string; kind: string; text: string } }): boolean { return (trigger.delivery_id === undefined || trigger.delivery_id === request.event.id) && trigger.agent_id === request.agentId && trigger.wake_kind === request.event.kind && trigger.text_sha256 === createHash("sha256").update(request.event.text).digest("hex"); } diff --git a/src/runtime/testRuntimeMoltnetActions.test.ts b/src/runtime/testRuntimeMoltnetActions.test.ts new file mode 100644 index 0000000..523a981 --- /dev/null +++ b/src/runtime/testRuntimeMoltnetActions.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createScriptedMoltnetActions } from "./testRuntimeMoltnetActions.js"; +import { occurrenceFor } from "./schedule.js"; + +test("scripted Moltnet actions accept actual durable cron and every delivery ids", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-test-moltnet-action-")); + try { + const config = path.join(root, "client.json"); + await writeFile(config, JSON.stringify({ version: "moltnet.client.v1", attachments: [{ network_id: "news", rooms: [{ id: "desk" }] }] })); + const at = Date.parse("2026-08-16T14:00:00.000Z"); + const deliveryIds = [ + occurrenceFor("alpha", { kind: "cron", cron: "0 16 * * *", timezone: "Europe/Berlin", prompt: "draft" }, at).deliveryId, + occurrenceFor("alpha", { kind: "every", interval_ms: 60_000, prompt: "draft" }, at).deliveryId + ]; + assert.match(deliveryIds[0]!, /T16:00@GMT\+02:00$/u); assert.match(deliveryIds[1]!, /\.000Z$/u); + const run = await createScriptedMoltnetActions(deliveryIds.map((delivery_id) => ({ delivery_id, network_id: "news", target: "room:desk", text: "draft" })), "/unused/moltnet", config); + assert.deepEqual(await run("schedule:not-the-occurrence"), []); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("scripted Moltnet actions reject unbounded or malformed schedule ids", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-test-moltnet-action-invalid-")); + try { + const config = path.join(root, "client.json"); + await writeFile(config, JSON.stringify({ version: "moltnet.client.v1", attachments: [{ network_id: "news", rooms: [{ id: "desk" }] }] })); + await assert.rejects(createScriptedMoltnetActions([{ delivery_id: `schedule:${"a".repeat(64)}:invalid`, network_id: "news", target: "room:desk", text: "draft" }], "/unused/moltnet", config), /invalid scripted Moltnet action/); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/runtime/testRuntimeMoltnetActions.ts b/src/runtime/testRuntimeMoltnetActions.ts new file mode 100644 index 0000000..c263d06 --- /dev/null +++ b/src/runtime/testRuntimeMoltnetActions.ts @@ -0,0 +1,66 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +import { isCanonicalScheduleDeliveryId } from "./schedule.js"; + +const run = promisify(execFile); +const MAX_ACTIONS = 16; +const MAX_TEXT_BYTES = 16_384; + +export type ScriptedMoltnetAction = Readonly<{ delivery_id: string; network_id: string; target: string; text: string }>; +export type ScriptedMoltnetReceipt = Readonly<{ delivery_id: string; message_id: string; network_id: string; target: string }>; + +export async function createScriptedMoltnetActions(value: unknown, cliPath: unknown, configPath: unknown): Promise<(deliveryId: string) => Promise> { + const actions = parseActions(value); + if (actions.length === 0) return async () => []; + if (!absolute(cliPath) || !absolute(configPath)) throw new Error("scripted Moltnet actions require absolute CLI and client config paths"); + const config = parseConfig(JSON.parse(await readFile(configPath, "utf8"))); + for (const action of actions) assertDeclared(config, action); + return async (deliveryId) => { + const receipts: ScriptedMoltnetReceipt[] = []; + for (const action of actions.filter((candidate) => candidate.delivery_id === deliveryId)) { + const { stdout } = await run(cliPath, ["send", "--config", configPath, "--network", action.network_id, "--target", action.target, "--text", action.text], { + env: { ...process.env, DAIMON_WAKE_ID: deliveryId }, maxBuffer: 65_536, timeout: 10_000 + }); + const receipt = record(JSON.parse(stdout)); + if (receipt.accepted !== true || typeof receipt.message_id !== "string" || !receipt.message_id) throw new Error("Moltnet action was not accepted"); + receipts.push({ delivery_id: deliveryId, message_id: receipt.message_id, network_id: action.network_id, target: action.target }); + } + return receipts; + }; +} + +function parseActions(value: unknown): ScriptedMoltnetAction[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > MAX_ACTIONS) throw new Error("invalid scripted Moltnet actions"); + return value.filter((item) => record(item).type !== "mcp_call").map((item) => { + const row = record(item); exact(row, ["delivery_id", "network_id", "target", "text"]); + if (![row.delivery_id, row.network_id, row.target, row.text].every(nonblank) || !deliveryId(row.delivery_id as string) || Buffer.byteLength(row.text as string) > MAX_TEXT_BYTES || !/^(?:room|dm):[^\s:]+$/u.test(row.target as string)) throw new Error("invalid scripted Moltnet action"); + return row as unknown as ScriptedMoltnetAction; + }); +} +function parseConfig(value: unknown): { attachments: Array> } { + const root = record(value); + if (root.version !== "moltnet.client.v1" || !Array.isArray(root.attachments)) throw new Error("invalid compiled Moltnet client config"); + return { attachments: root.attachments.map(record) }; +} +function assertDeclared(config: { attachments: Array> }, action: ScriptedMoltnetAction): void { + const attachment = config.attachments.find((item) => item.network_id === action.network_id); + if (!attachment) throw new Error("scripted Moltnet network is not declared"); + const [kind, id] = action.target.split(":", 2) as ["room" | "dm", string]; + if (kind === "room") { + const rooms = Array.isArray(attachment.rooms) ? attachment.rooms.map(record) : []; + if (!rooms.some((room) => room.id === id)) throw new Error("scripted Moltnet room is not declared"); + } else { + const dms = record(attachment.dms); + if (dms.enabled !== true) throw new Error("scripted Moltnet DMs are not declared"); + } +} +function record(value: unknown): Record { if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid object"); return value as Record; } +function exact(value: Record, keys: string[]): void { if (Object.keys(value).sort().join("\0") !== [...keys].sort().join("\0")) throw new Error("unexpected scripted Moltnet action field"); } +function nonblank(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +function absolute(value: unknown): value is string { return nonblank(value) && value.startsWith("/"); } +function deliveryId(value: string): boolean { + return value.length <= 256 && (/^moltnet:[A-Za-z0-9._~-]+$/u.test(value) || isCanonicalScheduleDeliveryId(value)); +} diff --git a/src/runtime/testRuntimeSubprocess.test.ts b/src/runtime/testRuntimeSubprocess.test.ts new file mode 100644 index 0000000..ddfe4ea --- /dev/null +++ b/src/runtime/testRuntimeSubprocess.test.ts @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { createInterface } from "node:readline"; +import test from "node:test"; + +const entrypoint = path.resolve("src/runtime/testRuntimeSubprocess.ts"); +const config = { + version: "noopolis.daimon.organization-runtime.v2", + host: { bindHost: "127.0.0.1", port: 4318, controlTokenEnv: "IGNORED_TEST_TOKEN" }, + agents: [{ id: "alpha", name: "Alpha", instructions: "work", workspacePath: "/workspace/alpha", runtimeHomePath: "/runtime/alpha", engine: { kind: "codex" }, schedule: { kind: "every", interval_ms: 1_000, prompt: "scheduled work" } }] +}; + +test("explicit subprocess drives real durable schedule acceptance and restart without duplication", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-test-runtime-")); await chmod(root, 0o700); + try { + const first = runtime(); + const started = await first.command({ type: "start", acceptance_store_path: root, config, control_token: "test-control", now_ms: 0 }) as { base_url: string }; + assert.match(started.base_url, /^http:\/\/127\.0\.0\.1:\d+$/u); + const advanced = await first.command({ type: "advance", now_ms: 1_000 }) as { wakes: Array<{ wake_id: string }> }; + assert.equal(advanced.wakes.length, 1); + const wakeId = advanced.wakes[0]!.wake_id; + const stopped = await first.command({ type: "stop" }) as { wakes: Array<{ wake_id: string }> }; + assert.deepEqual(stopped.wakes.map((wake) => wake.wake_id), [wakeId]); + await first.close(); + + const replacement = runtime(); + await replacement.command({ type: "start", acceptance_store_path: root, config, control_token: "test-control", now_ms: 1_000 }); + const replay = await replacement.command({ type: "snapshot" }) as { wakes: unknown[] }; + assert.equal(replay.wakes.length, 0); + const next = await replacement.command({ type: "advance", now_ms: 2_000 }) as { wakes: Array<{ wake_id: string }> }; + assert.equal(next.wakes.length, 1); + assert.notEqual(next.wakes[0]!.wake_id, wakeId); + await replacement.command({ type: "stop" }); await replacement.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("loopback v2 bridge durably accepts, authenticates, and deduplicates across restart", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-test-runtime-http-")); await chmod(root, 0o700); + const body = { agent_id: "alpha", delivery_id: "bridge-delivery", event: { version: "noopolis.daimon.wake.v2", kind: "external", text: "sensor value", occurred_at: "1970-01-01T00:00:00.000Z" } }; + try { + const first = runtime(); + const started = await first.command({ type: "start", acceptance_store_path: root, config, control_token: "bridge-token", now_ms: 0 }) as { base_url: string }; + assert.equal((await post(started.base_url, body, "wrong")).status, 401); + const accepted = await post(started.base_url, body, "bridge-token"); + assert.equal(accepted.status, 202); + await settles(first, 1); + await first.command({ type: "stop" }); await first.close(); + + const replacement = runtime(); + const restarted = await replacement.command({ type: "start", acceptance_store_path: root, config, control_token: "bridge-token", now_ms: 0 }) as { base_url: string }; + const replay = await post(restarted.base_url, body, "bridge-token"); + assert.equal(replay.status, 202); + await new Promise((resolve) => setTimeout(resolve, 25)); + const snapshot = await replacement.command({ type: "snapshot" }) as { wakes: unknown[] }; + assert.equal(snapshot.wakes.length, 0); + await replacement.command({ type: "stop" }); await replacement.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("explicit test runtime supports a bounded container HTTP bind", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-test-runtime-bind-")); await chmod(root, 0o700); + try { + const child = runtime(); + const started = await child.command({ type: "start", acceptance_store_path: root, config, control_token: "bind-token", now_ms: 0, http_host: "0.0.0.0", http_port: 0 }) as { base_url: string; http_host: string; http_port: number }; + assert.equal(started.http_host, "0.0.0.0"); assert.ok(started.http_port > 0); assert.equal(started.base_url, `http://0.0.0.0:${started.http_port}`); + assert.equal((await post(`http://127.0.0.1:${started.http_port}`, { agent_id: "alpha", delivery_id: "container-bind", event: { version: "noopolis.daimon.wake.v2", kind: "external", text: "reachable", occurred_at: "1970-01-01T00:00:00.000Z" } }, "bind-token")).status, 202); + await child.command({ type: "stop" }); await child.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("scripted cognition uses the real Moltnet CLI path to address a declared outbound wake", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "daimon-test-runtime-action-")); + const senderRoot = path.join(directory, "sender"); const recipientRoot = path.join(directory, "recipient"); + await Promise.all([chmod(directory, 0o700), mkdtemp(`${senderRoot}-`), mkdtemp(`${recipientRoot}-`)]); + const actualSenderRoot = (await import("node:fs/promises")).readdir(directory).then((names) => path.join(directory, names.find((name) => name.startsWith("sender-"))!)); + const actualRecipientRoot = (await import("node:fs/promises")).readdir(directory).then((names) => path.join(directory, names.find((name) => name.startsWith("recipient-"))!)); + const sender = runtime(); const recipient = runtime(); + try { + const recipientStart = await recipient.command({ type: "start", acceptance_store_path: await actualRecipientRoot, config, control_token: "recipient-token", now_ms: 0 }) as { base_url: string }; + const bridge = createServer(async (request, response) => { + if (request.method !== "POST" || request.url !== "/v1/messages") { response.writeHead(404).end(); return; } + const body = await new Promise((resolve) => { let value = ""; request.on("data", (chunk) => { value += chunk; }); request.on("end", () => resolve(value)); }); + const sent = JSON.parse(body) as { parts: Array<{ text: string }> }; + await post(recipientStart.base_url, { agent_id: "alpha", delivery_id: "moltnet:outgoing_1", event: { version: "noopolis.daimon.wake.v2", kind: "message", text: sent.parts[0]!.text, occurred_at: "1970-01-01T00:00:00.000Z" } }, "recipient-token"); + response.writeHead(202, { "content-type": "application/json" }).end(JSON.stringify({ accepted: true, event_id: "moltnet:outgoing_1", message_id: "outgoing_1" })); + }); + await new Promise((resolve) => bridge.listen(0, "127.0.0.1", resolve)); + const address = bridge.address(); assert.ok(address && typeof address !== "string"); + const clientConfig = path.join(directory, "config.json"); + await writeFile(clientConfig, JSON.stringify({ version: "moltnet.client.v1", agent: { name: "Alpha", runtime: "daimon" }, attachments: [{ agent_name: "Alpha", auth: { mode: "none" }, base_url: `http://127.0.0.1:${address.port}`, member_id: "alpha", network_id: "test-network", runtime: "daimon", rooms: [{ id: "dispatch" }] }] }), { mode: 0o600 }); + const senderStart = await sender.command({ type: "start", acceptance_store_path: await actualSenderRoot, config, control_token: "sender-token", now_ms: 0, + cognition_actions: [{ delivery_id: "moltnet:incoming_1", network_id: "test-network", target: "room:dispatch", text: "addressed result" }], + moltnet_cli_path: path.resolve("../moltnet/bin/moltnet"), moltnet_client_config_path: clientConfig }) as { base_url: string }; + assert.equal((await post(senderStart.base_url, { agent_id: "alpha", delivery_id: "moltnet:incoming_1", event: { version: "noopolis.daimon.wake.v2", kind: "message", text: "inbound", occurred_at: "1970-01-01T00:00:00.000Z" } }, "sender-token")).status, 202); + await settles(sender, 1); await settles(recipient, 1); + const senderEvidence = await sender.command({ type: "snapshot" }) as { action_receipts: Array<{ target: string }> }; + assert.deepEqual(senderEvidence.action_receipts.map((item) => item.target), ["room:dispatch"]); + await sender.command({ type: "stop" }); await recipient.command({ type: "stop" }); + await Promise.all([sender.close(), recipient.close(), new Promise((resolve, reject) => bridge.close((error) => error ? reject(error) : resolve()))]); + } finally { await rm(directory, { recursive: true, force: true }); } +}); + +test("scripted cognition calls only an attested compiled MCP server and tool", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-test-runtime-mcp-")); await chmod(root, 0o700); + try { + const artifact = { version: "spawnfile.explicit-test-mcp.v1", compile_fingerprint: "sf1:0123456789ab", servers: [{ id: "fixture", agent_id: "alpha", command: process.execPath, args: [path.resolve("src/runtime/fixtures/testMcpServer.mjs")], tools: ["checkpoint"], env_names: [] }] }; + const artifactBytes = Buffer.from(`${JSON.stringify(artifact)}\n`); const configPath = path.join(root, "mcp.json"), receiptPath = path.join(root, "receipt.json"); + await writeFile(configPath, artifactBytes, { mode: 0o600 }); await writeFile(receiptPath, JSON.stringify({ version: "spawnfile.explicit-test-mcp-receipt.v1", artifact_sha256: `sha256:${(await import("node:crypto")).createHash("sha256").update(artifactBytes).digest("hex")}` }), { mode: 0o600 }); + const child = runtime(); const started = await child.command({ type: "start", acceptance_store_path: root, config, control_token: "mcp-token", now_ms: 0, mcp_config_path: configPath, mcp_receipt_path: receiptPath, cognition_actions: [{ type: "mcp_call", trigger: { agent_id: "alpha", wake_kind: "schedule", text_sha256: (await import("node:crypto")).createHash("sha256").update("scheduled work").digest("hex") }, server_id: "fixture", tool: "checkpoint", arguments: { phase: "drafting" } }] }) as { base_url: string }; + const advanced = await child.command({ type: "advance", now_ms: 1_000 }) as { action_receipts: Array<{ type: string; tool: string; is_error: boolean }> }; + assert.deepEqual(advanced.action_receipts.map(({ type, tool, is_error }) => ({ type, tool, is_error })), [{ type: "mcp_call", tool: "checkpoint", is_error: false }]); + await child.command({ type: "stop" }); await child.close(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("subprocess entrypoint is unavailable without explicit test mode", async () => { + const child = spawn(process.execPath, ["--import", "tsx", entrypoint], { env: { ...process.env, DAIMON_EXPLICIT_TEST_RUNTIME: "" }, stdio: ["ignore", "ignore", "pipe"] }); + let error = ""; child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { error += chunk; }); + const code = await new Promise((resolve) => child.once("exit", resolve)); + assert.notEqual(code, 0); assert.match(error, /requires DAIMON_EXPLICIT_TEST_RUNTIME=1/); +}); + +function runtime(): { command(value: unknown): Promise; close(): Promise } { + const child = spawn(process.execPath, ["--import", "tsx", entrypoint], { env: { ...process.env, DAIMON_EXPLICIT_TEST_RUNTIME: "1" }, stdio: ["pipe", "pipe", "pipe"] }); + const lines = createInterface({ input: child.stdout }); + const replies: Array<(value: unknown) => void> = []; + lines.on("line", (line) => replies.shift()?.(JSON.parse(line))); + return { + command: async (value) => await new Promise((resolve, reject) => { + replies.push(resolve); + child.stdin.write(`${JSON.stringify(value)}\n`, (error) => { if (error) reject(error); }); + }), + close: async () => { + child.stdin.end(); + const code = await new Promise((resolve) => child.once("exit", resolve)); + if (code !== 0) throw new Error(`test runtime exited ${code}`); + } + }; +} + +async function post(baseUrl: string, body: unknown, token: string): Promise { + return await fetch(`${baseUrl}/v2/wakes`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body) }); +} +async function settles(child: ReturnType, expected: number): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const value = await child.command({ type: "snapshot" }) as { wakes: unknown[] }; + if (value.wakes.length === expected) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("HTTP wake did not settle"); +} diff --git a/src/runtime/testRuntimeSubprocess.ts b/src/runtime/testRuntimeSubprocess.ts new file mode 100644 index 0000000..9ecec62 --- /dev/null +++ b/src/runtime/testRuntimeSubprocess.ts @@ -0,0 +1,180 @@ +import { createHash } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { createInterface } from "node:readline"; + +import { + type OrganizationRuntimeActivityPage, + type OrganizationRuntimeHealth, + type OrganizationRuntimeHost, + type OrganizationRuntimeShutdownCompletion, + type OrganizationRuntimeWakeRequest, + type OrganizationRuntimeWakeResult +} from "./organizationRuntime.js"; +import { + createOrganizationRuntimeControlHostWithCoreForTest, + type OrganizationRuntimeControlHost +} from "./organizationRuntimeControl.js"; +import { MAX_WAKE_ACCEPTANCE_BYTES } from "./wakeAcceptanceTypes.js"; +import { createScriptedMoltnetActions, type ScriptedMoltnetReceipt } from "./testRuntimeMoltnetActions.js"; +import { createScriptedMcpActions, type ScriptedMcpReceipt } from "./testRuntimeMcpActions.js"; + +if (process.env.DAIMON_EXPLICIT_TEST_RUNTIME !== "1") { + throw new Error("Daimon test runtime requires DAIMON_EXPLICIT_TEST_RUNTIME=1"); +} + +type Timer = ReturnType; +type Command = + | Readonly<{ type: "start"; acceptance_store_path: string; config: unknown; control_token: string; now_ms: number; http_host?: unknown; http_port?: unknown; cognition_actions?: unknown; moltnet_cli_path?: unknown; moltnet_client_config_path?: unknown; mcp_config_path?: unknown; mcp_receipt_path?: unknown }> + | Readonly<{ type: "advance"; now_ms: number }> + | Readonly<{ type: "snapshot" }> + | Readonly<{ type: "stop" }>; + +class ControlledClock { + now = 0; + private sequence = 0; + private readonly timers = new Map void; due: number }>(); + readonly options = { + now: (): number => this.now, + setTimer: ((callback: () => void, delay: number): Timer => { + const id = ++this.sequence; + this.timers.set(id, { callback, due: this.now + delay }); + return { id, unref() {} } as unknown as Timer; + }), + clearTimer: ((timer: Timer): void => { + this.timers.delete((timer as unknown as { id: number }).id); + }) + }; + async advance(value: number): Promise { + if (!Number.isSafeInteger(value) || value < this.now) throw new Error("test clock must advance monotonically"); + this.now = value; let fired = 0; + while (true) { + const due = [...this.timers.entries()].filter(([, timer]) => timer.due <= value).sort((left, right) => left[1].due - right[1].due || left[0] - right[0]); + if (due.length === 0) break; + for (const [id, timer] of due) { if (this.timers.delete(id)) { fired += 1; timer.callback(); } } + await turn(); + } + await turn(); + return fired; + } +} + +class ScriptedCore implements OrganizationRuntimeHost { + readonly wakes: Array<{ agent_id: string; kind: string; occurred_at: string; result_digest: string; wake_id: string }> = []; + readonly actionReceipts: Array = []; + actions: (request: OrganizationRuntimeWakeRequest) => Promise> = async () => []; + async start(): Promise {} + async wake(request: OrganizationRuntimeWakeRequest): Promise { + this.actionReceipts.push(...await this.actions(request)); + const text = `scripted:${createHash("sha256").update(request.event.id).digest("hex")}`; + this.wakes.push({ agent_id: request.agentId, kind: request.event.kind, occurred_at: request.event.occurredAt, result_digest: text.slice("scripted:".length), wake_id: request.event.id }); + return { version: "noopolis.daimon.wake-result.v1", status: "completed", agentId: request.agentId, wakeId: request.event.id, text, durationMs: 0 }; + } + async health(): Promise { return { version: "noopolis.daimon.organization-runtime-health.v1", state: "running", agents: [] }; } + async activity(): Promise { return { version: "noopolis.daimon.organization-runtime-activity.v1", items: [] }; } + async stop(): Promise { return { version: "noopolis.daimon.organization-runtime-stop.v1", state: "stopped" }; } +} + +const clock = new ControlledClock(); +const core = new ScriptedCore(); +let control: OrganizationRuntimeControlHost | undefined; +let token: string | undefined; +let server: Server | undefined; + +const respond = (value: unknown): void => { process.stdout.write(`${JSON.stringify(value)}\n`); }; +const snapshot = async (): Promise => ({ + activity: control && token ? await control.activityV2(token) : undefined, + action_receipts: core.actionReceipts, + now_ms: clock.now, + type: "snapshot", + wakes: core.wakes +}); +const execute = async (command: Command): Promise => { + if (command.type === "start") { + if (control !== undefined || !pathValue(command.acceptance_store_path) || !nonblank(command.control_token) || !Number.isSafeInteger(command.now_ms) || command.now_ms < 0) throw new Error("invalid test runtime start"); + const httpHost = command.http_host === undefined ? "127.0.0.1" : command.http_host; + const httpPort = command.http_port === undefined ? 0 : command.http_port; + if ((httpHost !== "127.0.0.1" && httpHost !== "0.0.0.0") || !Number.isSafeInteger(httpPort) || (httpPort as number) < 0 || (httpPort as number) > 65_535) throw new Error("invalid test runtime HTTP bind"); + clock.now = command.now_ms; token = command.control_token; + const moltnetActions = await createScriptedMoltnetActions(command.cognition_actions, command.moltnet_cli_path, command.moltnet_client_config_path); + const mcpActions = await createScriptedMcpActions(command.cognition_actions, command.mcp_config_path, command.mcp_receipt_path); + core.actions = async (request) => [...await moltnetActions(request.event.id), ...await mcpActions(request)]; + control = createOrganizationRuntimeControlHostWithCoreForTest(command.config, core, { + acceptanceStorePath: command.acceptance_store_path, + controlToken: token, + scheduleOptions: clock.options, + storeOptions: { + nowForTest: () => clock.now, + ownerLiveness: async () => false, + processIdentity: async () => ({ pid: process.pid, process_start: "explicit-test-runtime", boot_id: "explicit-test-runtime", pid_namespace_dev: 1, pid_namespace_ino: 1 }) + } + }); + await control.start(); await turn(); + server = createServer(async (request, response) => { + try { + if (request.method !== "POST" || request.url !== "/v2/wakes") return json(response, 404, { error: "not_found" }); + if (request.headers.authorization !== `Bearer ${token}`) return json(response, 401, { error: "unauthorized" }); + if (request.headers["content-type"] !== "application/json") return json(response, 400, { error: "invalid_content_type" }); + const body = JSON.parse(await readBody(request)); + const result = await control!.accept({ ...objectBody(body), token }); + const status = result.state === "accepted" ? 202 : result.state === "stopped" ? 409 : result.code === "invalid_request" ? 400 : result.code === "unauthorized" ? 401 : 409; + return json(response, status, result); + } catch { return json(response, 400, { error: "invalid_request" }); } + }); + await new Promise((resolve, reject) => { server!.once("error", reject); server!.listen(httpPort as number, httpHost, resolve); }); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("test runtime listener did not bind"); + return { base_url: `http://${httpHost}:${address.port}`, http_host: httpHost, http_port: address.port, now_ms: clock.now, type: "started" }; + } + if (control === undefined) throw new Error("test runtime is not started"); + if (command.type === "advance") { + const before = core.wakes.length; + const fired = await clock.advance(command.now_ms); + await waitFor(() => core.wakes.length >= before + fired); + return await snapshot(); + } + if (command.type === "snapshot") return await snapshot(); + await closeServer(); + await control.stop(); control = undefined; + return { action_receipts: core.actionReceipts, now_ms: clock.now, type: "stopped", wakes: core.wakes }; +}; + +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); +for await (const line of input) { + try { respond(await execute(JSON.parse(line) as Command)); } + catch (error) { respond({ error: error instanceof Error ? error.message : "test runtime failed", type: "error" }); } +} +await closeServer(); +await control?.stop(); + +function nonblank(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +function pathValue(value: unknown): value is string { return nonblank(value) && value.startsWith("/"); } +async function turn(): Promise { await new Promise((resolve) => setImmediate(resolve)); } +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 5_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("test runtime did not settle"); + await new Promise((resolve) => setTimeout(resolve, 2)); + } +} +async function closeServer(): Promise { + if (server === undefined) return; + const active = server; server = undefined; + await new Promise((resolve, reject) => active.close((error) => error ? reject(error) : resolve())); +} +async function readBody(request: import("node:http").IncomingMessage): Promise { + const chunks: Buffer[] = []; let size = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_WAKE_ACCEPTANCE_BYTES) { request.destroy(); throw new Error("request too large"); } + chunks.push(buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} +function objectBody(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid body"); + return value as Record; +} +function json(response: import("node:http").ServerResponse, status: number, value: unknown): void { + response.writeHead(status, { "content-type": "application/json" }); response.end(JSON.stringify(value)); +} diff --git a/tsconfig.build.json b/tsconfig.build.json index c2f08f7..843391e 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,6 +1,10 @@ { "extends": "./tsconfig.json", "exclude": [ - "src/**/*.test.ts" + "src/**/*.test.ts", + "src/runtime/testRuntimeMoltnetActions.ts", + "src/runtime/testRuntimeMcpActions.ts", + "src/runtime/fixtures/testMcpServer.mjs", + "src/runtime/testRuntimeSubprocess.ts" ] } diff --git a/tsconfig.test-runtime.json b/tsconfig.test-runtime.json new file mode 100644 index 0000000..bd56e32 --- /dev/null +++ b/tsconfig.test-runtime.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "declaration": false, "declarationMap": false, "outDir": "dist-test-runtime", "sourceMap": false }, + "exclude": ["src/**/*.test.ts"] +} From 5eddf409525dea482b605007a40141295844989b Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:42:06 +0200 Subject: [PATCH 09/24] docs(runtime): document autonomous brokered agent execution --- README.md | 43 +++++++++++++++++++++++++++++++------------ src/runtime/AGENTS.md | 30 ++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 45430c8..2542cfc 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,11 @@ top of Pi. A Daimon runs one harnessed agent inside a caller-prepared workspace. Spawnfile compiles and deploys orgs, nested teams, member-owned schedules, Moltnet wiring, and workspace resources. Daimon executes one agent runtime: it accepts a wake selected by that runtime's organization policy and runs one -turn. It does not know the org graph, schedule other agents, or let Simfile or -a world service trigger cognition. +turn. Its strict organization-runtime v2 host owns each configured agent's +durable `cron`, `every`, or `disabled` schedule without learning the Spawnfile +org graph. Simfile and world services do not trigger cognition. + +Spawnfile may also compile per-agent production MCP and Moltnet capabilities into this config. Daimon lists each MCP server at startup, mounts only its declared tool allowlist into Codex/Grok cognition turns, and bounds calls to 10 seconds and 64 KiB. Moltnet exposes one scoped natural-language send tool whose deterministic delivery id derives from the active wake, agent, target, and text. Accepted tool receipts are fsynced under the agent runtime home and replayed after restart; transport policy remains Spawnfile/Moltnet-owned. For a world-capable `kind: every` wake, the harness starts without a decision token and privately calls `world_claim` before exposing any other world tool. @@ -77,9 +80,16 @@ const config = parseOrganizationRuntimeConfig({ }); ``` -It intentionally contains no teams, roles, parent/member links, schedules, -wake policies, deployment settings, Moltnet data, commands, argument arrays, -environment maps, or credentials. `controlTokenEnv` is only the safe name of a +The package also includes the canonical runtime contract at +`dist/runtime/contract-manifest.json` and its exact-byte SHA-256 sidecar at +`dist/runtime/contract-manifest.sha256`. Every build regenerates both from the +same data-only constants exported as `RUNTIME_CONTRACT_MANIFEST`; the digest is +encoded as `sha256:`. + +It intentionally contains no teams, roles, parent/member links, wake policies, +deployment settings, Moltnet data, commands, argument arrays, environment maps, +or credentials. V2 agents carry one normalized native schedule; durable state +and acceptance provide restart-safe occurrence execution. `controlTokenEnv` is only the safe name of a variable; the token value is never serialized. The only organization-host engine intents are `codex`, `grok`, and `agy`; Pi remains available through the separate one-agent `@noopolis/daimon/pi` API. @@ -192,13 +202,22 @@ not be group/other writable. Daimon never creates or removes these roots. For a production CLI engine, Daimon resolves the engine from `PATH` once, pins its canonical executable identity, probes only `--version`, and rechecks -that identity before every child process. Codex and Grok use caller-provisioned -private `.codex/auth.json` and `.grok/auth.json` refresh-token files beneath -each agent's `runtimeHomePath`. Their children receive only the matching -engine home, runtime/XDG paths, locale/timezone, and a PATH sufficient for the -already pinned executable. They never inherit arbitrary host variables. -Missing, replaced, malformed, or unsafe engine authority prevents startup or -fails the affected wake without publishing credential contents or file paths. +that identity before every child process. Codex uses a caller-provisioned +private `.codex/auth.json` refresh credential beneath each agent's +`runtimeHomePath`. Grok instead uses one host-wide durable subscription realm: +a read-only operator bootstrap seeds the authority, and Daimon serializes +turns while staging and reconciling the rotating credential into each private +agent home. Sessions and non-auth state remain isolated per agent. Engine +children receive only the matching engine home, runtime/XDG paths, +locale/timezone, and a PATH sufficient for the already pinned executable. +Before every Grok process, Daimon rewrites its exact custom sandbox profile and +requires a fresh kernel-enforcement receipt covering the durable realm, +read-only bootstrap, and every peer workspace/runtime home. A fail-open +Landlock/Seatbelt warning, missing deny path, or profile mutation fails the +wake before cognition. +They never inherit arbitrary host variables. Missing, replaced, malformed, +stale, or unsafe engine authority prevents startup or fails the affected wake +without publishing credential contents or file paths. AGY subscription authentication is different: it uses OS-native secure storage. Daimon supervises one private D-Bus plus Secret Service realm for all diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md index 3ffe7c4..1b15cf7 100644 --- a/src/runtime/AGENTS.md +++ b/src/runtime/AGENTS.md @@ -1,8 +1,8 @@ # Daimon organization-runtime contract This folder owns the versioned, organization-neutral contract and host for -isolated Daimon agents. It is not a compiler, organization graph, scheduler, -Moltnet adapter, or deployment surface. +isolated Daimon agents and their runtime-native durable schedules. It is not a +compiler, organization graph, Moltnet adapter, or deployment surface. Keep config parsing pure and strict. The config must never contain credentials, commands, argument arrays, arbitrary environment maps, process handles, or @@ -17,3 +17,29 @@ cover. realm, durable keyring lease, bounded unlock stdin, and cleanup. `agySubscriptionBootstrap.ts` owns only the interactive first-enrollment AGY child; normal engine dispatch remains in `engineDispatcher.ts`. +`portableCredentialMaterial.ts` imports bounded Codex ingress into its +runtime-writable home without clobbering a newer CLI-refreshed credential. +`grokSubscriptionRealm.ts` owns the single durable rotating Grok credential, +the lifetime lease, crash journal, stale fence, and serialized per-turn +stage/promote cycle while each agent retains private non-auth home state. +`../pi/grokSandbox.ts` owns the production Grok process boundary: it replaces +the provider's fail-open built-in profile with an exact custom profile denying +the realm, bootstrap, and peer roots, and requires a kernel-enforcement event +before every Grok setup, turn, and cleanup process. +`organizationRuntimeReadiness.ts` composes portable credential preparation, +AGY realm readiness, and physical path authority before any agent starts. +`testRuntimeSubprocess.ts` is an unexported, explicit-test-only JSONL process +surface for exercising the real control, schedule, and acceptance paths with a +controlled clock and deterministic scripted cognition. Its ephemeral loopback +HTTP listener exposes only the authenticated v2 wake-acceptance route needed by +transport integration tests. Optional bounded cognition actions invoke the real +Moltnet CLI with an explicit compiled client config, and may address only +declared networks and room/DM surfaces. Optional stdio MCP calls consume only a +Spawnfile-compiled, digest-attested test artifact and enforce its agent/server/tool +allowlist. These modules build only into `dist-test-runtime`, never production +`dist`, and remain inert unless the fixed test-mode environment gate is present. + +Each engine/tool child receives only the current non-secret wake id in +`DAIMON_WAKE_ID`; it is bound for one turn and cleared afterward. Transports +may use it as an idempotency/cause key, but Daimon does not interpret transport +identities or targets. From 69b7c8a2e6803c23f160ea280a4ad863a54fff0a Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:43:48 +0200 Subject: [PATCH 10/24] style(native): normalize broker source endings --- src/runtime/native/engineBrokerLauncherCore.inc | 1 - src/runtime/native/engineBrokerLauncherModes.inc | 1 - 2 files changed, 2 deletions(-) diff --git a/src/runtime/native/engineBrokerLauncherCore.inc b/src/runtime/native/engineBrokerLauncherCore.inc index 8329892..5423617 100644 --- a/src/runtime/native/engineBrokerLauncherCore.inc +++ b/src/runtime/native/engineBrokerLauncherCore.inc @@ -345,4 +345,3 @@ static pid_t launch(const struct dbl_registration *r, int executable, erase(mcp_env, sizeof(mcp_env)); launch_fail(status_pipe[1], 6); } - diff --git a/src/runtime/native/engineBrokerLauncherModes.inc b/src/runtime/native/engineBrokerLauncherModes.inc index ac78544..2b087da 100644 --- a/src/runtime/native/engineBrokerLauncherModes.inc +++ b/src/runtime/native/engineBrokerLauncherModes.inc @@ -248,4 +248,3 @@ static int relay_mode(void) { close(listener); return -1; } - From 759f425c5d4ef27f4d32d860a96fbec7515d62ec Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:50:20 +0200 Subject: [PATCH 11/24] chore(build): ignore generated test runtime --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2c9109e..693d3f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules/ dist/ +dist-test-runtime/ .runtime/ *.tsbuildinfo - From 3b9ceeb4a2f1646e501f283d49399bf280483468 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:36:16 +0200 Subject: [PATCH 12/24] fix(native): restore broker source trailing newlines to match committed provenance --- src/runtime/native/engineBrokerLauncherCore.inc | 1 + src/runtime/native/engineBrokerLauncherModes.inc | 1 + 2 files changed, 2 insertions(+) diff --git a/src/runtime/native/engineBrokerLauncherCore.inc b/src/runtime/native/engineBrokerLauncherCore.inc index 5423617..8329892 100644 --- a/src/runtime/native/engineBrokerLauncherCore.inc +++ b/src/runtime/native/engineBrokerLauncherCore.inc @@ -345,3 +345,4 @@ static pid_t launch(const struct dbl_registration *r, int executable, erase(mcp_env, sizeof(mcp_env)); launch_fail(status_pipe[1], 6); } + diff --git a/src/runtime/native/engineBrokerLauncherModes.inc b/src/runtime/native/engineBrokerLauncherModes.inc index 2b087da..ac78544 100644 --- a/src/runtime/native/engineBrokerLauncherModes.inc +++ b/src/runtime/native/engineBrokerLauncherModes.inc @@ -248,3 +248,4 @@ static int relay_mode(void) { close(listener); return -1; } + From 7c2cd0e3e3f91a9461890be0e919cfb111dfd80d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:36:16 +0200 Subject: [PATCH 13/24] fix(observability): allocate causal sequence numbers atomically under concurrency --- src/observability/causalEvents.test.ts | 65 ++++++++++++++- src/observability/causalEvents.ts | 106 +++++++++++++++++++++---- 2 files changed, 156 insertions(+), 15 deletions(-) diff --git a/src/observability/causalEvents.test.ts b/src/observability/causalEvents.test.ts index adeb78c..7e2fa95 100644 --- a/src/observability/causalEvents.test.ts +++ b/src/observability/causalEvents.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -198,3 +199,65 @@ test("replyCauseEventIds is pure and matches the emitted turn.output.completed i // Deterministic from turn_id alone: no model output, no I/O, no dependency on emission having happened. assert.deepEqual(replyCauseEventIds("wake-9"), replyCauseEventIds("wake-9")); }); + +test("concurrent seq allocations in one process each receive a distinct increasing seq", async () => { + const runtimeHomePath = await tempDir(); + const stream = { runId: "run-1", runtimeHomePath, streamId: "agent:mapper" }; + + const allocated = await Promise.all(Array.from({ length: 64 }, () => nextCausalSeq(stream))); + + assert.deepEqual(allocated.toSorted((left, right) => left - right), Array.from({ length: 64 }, (_, index) => index + 1)); +}); + +test("concurrent seq allocations across separate processes each receive a distinct increasing seq", { timeout: 30_000 }, async () => { + const runtimeHomePath = await tempDir(); + const moduleUrl = new URL("./causalEvents.ts", import.meta.url).href; + const child = ` + import { nextCausalSeq } from ${JSON.stringify(moduleUrl)}; + const out = await Promise.all(Array.from({ length: 8 }, () => + nextCausalSeq({ runId: "run-1", runtimeHomePath: process.env.CAUSAL_TEST_HOME, streamId: "agent:mapper" }))); + process.stdout.write(JSON.stringify(out)); + `; + const repoRoot = new URL("../../", import.meta.url); + const runChild = (): Promise => new Promise((resolve, reject) => { + const processHandle = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", child], { + cwd: repoRoot, + env: { ...process.env, CAUSAL_TEST_HOME: runtimeHomePath } + }); + let stdout = ""; + let stderr = ""; + processHandle.stdout.setEncoding("utf8").on("data", (chunk: string) => { stdout += chunk; }); + processHandle.stderr.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; }); + processHandle.on("error", reject); + processHandle.on("close", (code) => { + if (code !== 0) reject(new Error(`child exited ${code}: ${stderr}`)); + else resolve(JSON.parse(stdout) as number[]); + }); + }); + + const allocated = (await Promise.all(Array.from({ length: 4 }, runChild))).flat(); + + assert.deepEqual(allocated.toSorted((left, right) => left - right), Array.from({ length: 32 }, (_, index) => index + 1)); +}); + +test("the seq counter file is only ever observable as complete JSON, and leaves no temp files behind", async () => { + const runtimeHomePath = await tempDir(); + const stream = { runId: "run-1", runtimeHomePath, streamId: "agent:mapper" }; + let complete = false; + const allocations = Promise.all(Array.from({ length: 32 }, () => nextCausalSeq(stream))).finally(() => { + complete = true; + }); + + while (!complete) { + try { + JSON.parse(await readFile(path.join(runtimeHomePath, "telemetry", "causal.seq.json"), "utf8")); + } catch (error) { + if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error; + } + } + await allocations; + + const entries = await readdir(path.join(runtimeHomePath, "telemetry")); + assert.equal(entries.some((entry) => /\.tmp$/u.test(entry)), false); + assert.equal(entries.includes("causal.seq.lock"), false); +}); diff --git a/src/observability/causalEvents.ts b/src/observability/causalEvents.ts index 1070bac..044b4ca 100644 --- a/src/observability/causalEvents.ts +++ b/src/observability/causalEvents.ts @@ -1,5 +1,6 @@ -import { createHash } from "node:crypto"; -import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { appendFile, mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises"; import path from "node:path"; /** @@ -86,6 +87,8 @@ export const replyCauseEventIds = (turnId: string): string[] => [turnOutputCompl const telemetryDir = (runtimeHomePath: string): string => path.join(runtimeHomePath, "telemetry"); const seqFilePath = (runtimeHomePath: string): string => path.join(telemetryDir(runtimeHomePath), "causal.seq.json"); const jsonlFilePath = (runtimeHomePath: string): string => path.join(telemetryDir(runtimeHomePath), "causal.jsonl"); +const STALE_LOCK_MS = 30_000; +const seqAllocationQueues = new Map>(); /** run_id -> stream_id -> last assigned seq. */ type CausalSeqStore = Record>; @@ -103,28 +106,103 @@ const readSeqStore = async (runtimeHomePath: string): Promise => }; const writeSeqStore = async (runtimeHomePath: string, store: CausalSeqStore): Promise => { - await mkdir(telemetryDir(runtimeHomePath), { recursive: true }); - await writeFile(seqFilePath(runtimeHomePath), `${JSON.stringify(store, null, 2)}\n`, "utf8"); + const directory = telemetryDir(runtimeHomePath); + await mkdir(directory, { recursive: true }); + const file = seqFilePath(runtimeHomePath); + const temporary = `${file}.${randomUUID()}.tmp`; + const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + try { + await handle.writeFile(`${JSON.stringify(store, null, 2)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporary, file); + await syncDirectory(directory); + } finally { + await unlink(temporary).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } +}; + +const syncDirectory = async (directory: string): Promise => { + const handle = await open(directory, constants.O_RDONLY); + try { + await handle.sync(); + } finally { + await handle.close(); + } +}; + +const acquireSeqLock = async (lockPath: string): Promise => { + const startedAt = Date.now(); + let backoffMs = 2; + while (Date.now() - startedAt < 5_000) { + try { + const handle = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + await handle.close(); + return; + } catch (error) { + if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error; + } + try { + const observed = await stat(lockPath); + if (Date.now() - observed.mtimeMs > STALE_LOCK_MS) { + const confirmed = await stat(lockPath); + if (confirmed.mtimeMs === observed.mtimeMs) { + await unlink(lockPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + continue; + } + } + } catch (error) { + if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error; + continue; + } + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + backoffMs = Math.min(backoffMs * 2, 25); + } + throw new Error(`Timed out acquiring causal sequence lock: ${lockPath}`); }; /** * Allocates the next contiguous seq number for `(run_id, stream_id)`, - * persisted under `runtimeHome/telemetry/causal.seq.json`. Daimon runs at - * most one wake at a time per agent (`PiAgentHandle.wakeQueue` serializes - * them), so read-modify-write here does not need extra locking. + * persisted under `runtimeHome/telemetry/causal.seq.json`. Allocation is + * serialized in-process and mutually excluded across processes by a lock + * file; the counter is written atomically and fsynced. */ export const nextCausalSeq = async (input: { runId: string; runtimeHomePath: string; streamId: string; }): Promise => { - const store = await readSeqStore(input.runtimeHomePath); - const forRun = store[input.runId] ?? {}; - const next = (forRun[input.streamId] ?? 0) + 1; - forRun[input.streamId] = next; - store[input.runId] = forRun; - await writeSeqStore(input.runtimeHomePath, store); - return next; + const lockPath = path.resolve(telemetryDir(input.runtimeHomePath), "causal.seq.lock"); + const previous = seqAllocationQueues.get(lockPath) ?? Promise.resolve(); + const allocation = previous.catch(() => undefined).then(async () => { + await mkdir(telemetryDir(input.runtimeHomePath), { recursive: true }); + await acquireSeqLock(lockPath); + try { + const store = await readSeqStore(input.runtimeHomePath); + const forRun = store[input.runId] ?? {}; + const next = (forRun[input.streamId] ?? 0) + 1; + forRun[input.streamId] = next; + store[input.runId] = forRun; + await writeSeqStore(input.runtimeHomePath, store); + return next; + } finally { + await unlink(lockPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + }); + seqAllocationQueues.set(lockPath, allocation); + void allocation.finally(() => { + if (seqAllocationQueues.get(lockPath) === allocation) seqAllocationQueues.delete(lockPath); + }).catch(() => undefined); + return allocation; }; /** Appends one CausalEvent record as a line of `runtimeHome/telemetry/causal.jsonl`. */ From c67ab7cc9a81d00e44fd62d9200a03e160002f6e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:36:16 +0200 Subject: [PATCH 14/24] fix(runtime): publish reconciliation leases via link so readers never see partial json --- .../wakeAcceptanceReconciliation.test.ts | 77 ++++++++++++++++++- src/runtime/wakeAcceptanceReconciliation.ts | 17 +++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/runtime/wakeAcceptanceReconciliation.test.ts b/src/runtime/wakeAcceptanceReconciliation.test.ts index a901071..fb2bd4e 100644 --- a/src/runtime/wakeAcceptanceReconciliation.test.ts +++ b/src/runtime/wakeAcceptanceReconciliation.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { chmod, lstat, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { constants, readFileSync } from "node:fs"; +import { chmod, lstat, mkdtemp, open, readFile, readdir, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { reconcileOfflineWakeTransition, type OfflineTransitionReconciliationRequest } from "./wakeAcceptanceReconciliation.js"; +import { publishExclusive, reconcileOfflineWakeTransition, type OfflineTransitionReconciliationRequest } from "./wakeAcceptanceReconciliation.js"; import { WakeAcceptanceStore } from "./wakeAcceptanceStore.js"; import { hostRegistrationDigest } from "./storeCoordination.js"; import { parseWakeAcceptanceRequest } from "./wakeAcceptanceTypes.js"; @@ -196,3 +197,75 @@ function receiptName(request: OfflineTransitionReconciliationRequest): string { async function hostRegistrations(root: string): Promise { return (await readdir(root)).filter((entry) => entry.startsWith(".host-online-")).sort(); } async function closeAbandonedStore(store: WakeAcceptanceStore): Promise { await (store as unknown as { directory: { close(): Promise } }).directory.close(); } async function privateRoot(): Promise { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-reconcile-")); await chmod(root, 0o700); return root; } + +test("the offline reconciliation lease is never published under its final name with unparseable content", async () => { + const root = await privateRoot(); + try { + const request = await emptyReconciliationRequest(root); + const leasePath = path.join(root, ".offline-reconciliation.lock"); + let torn = 0; + for (let trial = 0; trial < 25; trial += 1) { + let done = false; + const reconcile = reconcileOfflineWakeTransition(request, { + storePath: root, ...testLeaseOptions, + verifyDeploymentAttestation: async (context) => ({ request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }) + }).catch(() => undefined).finally(() => { done = true; }); + const probe = new Promise((resolve) => { + const tick = (): void => { + let raw: string | undefined; + try { raw = readFileSync(leasePath, "utf8"); } catch { raw = undefined; } + if (raw !== undefined) { try { JSON.parse(raw); } catch { torn += 1; } } + if (done) resolve(); else setImmediate(tick); + }; + setImmediate(tick); + }); + await Promise.all([reconcile, probe]); + } + assert.equal(torn, 0); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("a lease acquisition that loses the publish race is reported as blocked and leaves no temp files", async () => { + const root = await privateRoot(); + try { + const request = await emptyReconciliationRequest(root); + let reached!: () => void; + let release!: () => void; + const acquired = new Promise((resolve) => { reached = resolve; }); + const paused = new Promise((resolve) => { release = resolve; }); + const first = reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => { reached(); await paused; return { request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }; } }); + await acquired; + const second = await reconcileOfflineWakeTransition(request, { storePath: root, ...testLeaseOptions, verifyDeploymentAttestation: async (context) => ({ request_digest: context.request_digest, nonce: context.nonce, exclusive_store: true, authorized_registration_digests: [] }) }); + assert.equal(second.state, "blocked"); + release(); + await first; + assert.equal((await readdir(root)).some((entry) => /\.offline-reconciliation\.lock\./u.test(entry)), false); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("publishExclusive refuses to overwrite an existing name and leaves its content untouched", async () => { + const root = await privateRoot(); + try { + const directory = await open(root, constants.O_RDONLY); + try { + const target = path.join(root, "publish-target.json"); + const existing = JSON.stringify({ owner_id: "already-published" }); + await writeFile(target, existing, { mode: 0o600 }); + + await assert.rejects( + publishExclusive(target, { owner_id: "usurper" }, directory), + (error: NodeJS.ErrnoException) => error.code === "EEXIST" + ); + + // rename(2) would have silently overwritten both of these assertions away. + assert.equal(await readFile(target, "utf8"), existing); + assert.equal((await readdir(root)).some((entry) => entry.startsWith("publish-target.json.")), false); + + // The same primitive still publishes complete content onto a free name. + const fresh = path.join(root, "publish-fresh.json"); + await publishExclusive(fresh, { owner_id: "published" }, directory); + assert.deepEqual(JSON.parse(await readFile(fresh, "utf8")), { owner_id: "published" }); + assert.equal((await readdir(root)).some((entry) => entry.startsWith("publish-fresh.json.")), false); + } finally { await directory.close(); } + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/runtime/wakeAcceptanceReconciliation.ts b/src/runtime/wakeAcceptanceReconciliation.ts index 7cfc622..23571b8 100644 --- a/src/runtime/wakeAcceptanceReconciliation.ts +++ b/src/runtime/wakeAcceptanceReconciliation.ts @@ -100,7 +100,22 @@ async function openStore(root: string, expected: Identity): Promise<{ path: stri } catch (error) { await directory.close().catch(() => undefined); throw error; } } async function assertStore(root: { path: string; directory: Awaited> }, expected: Identity): Promise { await assertNoLinks(root.path); if (!same(await lstat(root.path), expected) || !same(await root.directory.stat(), expected) || await realpath(root.path) !== root.path) throw new OfflineTransitionReconciliationBlockedError(); } -async function acquireLease(root: string, directory: Awaited>, owner: Lease, liveness: (lease: Lease) => Promise): Promise { const target = path.join(root, ".offline-reconciliation.lock"); const current = await readLeaseOptional(target); if (current !== undefined) { if (!sameNamespace(current, owner) || await liveness(current)) return undefined; await unlink(target); await directory.sync(); } try { await writeNew(target, owner); await directory.sync(); return owner; } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined; throw error; } } +/** + * Publishes `value` at `target` only if `target` does not already exist, and + * only ever under complete, fsynced content — the name never appears holding a + * partial or zero-byte document, which `readLease` would otherwise accept as a + * safe file and then fail to `JSON.parse`. + * + * `link(2)` is mandatory here and `rename(2)` is FORBIDDEN. rename overwrites + * its destination unconditionally, so two acquirers that both pass the + * (non-atomic) `readLeaseOptional` pre-check would both publish and both + * believe they hold the exclusive offline-reconciliation lease. link fails with + * EEXIST when the target exists, which is the mutual exclusion `acquireLease` + * converts into a blocked result. Exported solely so that contract can be + * asserted directly; it is not re-exported from `./index.ts`. + */ +export async function publishExclusive(target: string, value: unknown, directory: Awaited>): Promise { const temp = `${target}.${randomUUID()}`; try { await writeNew(temp, value); await link(temp, target); await directory.sync(); } finally { await unlink(temp).catch(() => undefined); } } +async function acquireLease(root: string, directory: Awaited>, owner: Lease, liveness: (lease: Lease) => Promise): Promise { const target = path.join(root, ".offline-reconciliation.lock"); const current = await readLeaseOptional(target); if (current !== undefined) { if (!sameNamespace(current, owner) || await liveness(current)) return undefined; await unlink(target); await directory.sync(); } try { await publishExclusive(target, owner, directory); return owner; } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined; throw error; } } async function releaseLease(lease: Lease, directory: Awaited>, root: string): Promise { const target = path.join(root, ".offline-reconciliation.lock"); const current = await readLease(target); if (!sameLease(current, lease)) throw new OfflineTransitionReconciliationBlockedError(); await unlink(target); await directory.sync(); } function lockPath(root: string, agent: string, delivery: string): string { return path.join(root, `${createHash("sha256").update(`${agent}\u0000${delivery}`).digest("hex")}.transition-lock`); } function recordPath(root: string, agent: string, delivery: string): string { return path.join(root, `${createHash("sha256").update(`${agent}\u0000${delivery}`).digest("hex")}.json`); } From 882650d64a56caa7826c993137ca8df0eda0a0fa Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:36:22 +0200 Subject: [PATCH 15/24] feat(memory): wire Mneme recall and turn write-back through the organization runtime --- src/contracts/organizationRuntimeContract.ts | 5 + src/pi/piAgentHandle.ts | 10 +- src/pi/piAgentMemoryLoop.test.ts | 216 +++++++++++++++++++ src/pi/piHarnessMemory.test.ts | 8 +- src/pi/piHarnessSharedMemory.test.ts | 5 +- src/runtime/engineDispatcher.test.ts | 37 +++- src/runtime/engineDispatcher.ts | 16 +- src/runtime/organizationRuntime.test.ts | 64 ++++++ src/runtime/organizationRuntime.ts | 3 +- src/runtime/organizationRuntimeParsing.ts | 40 +++- 10 files changed, 392 insertions(+), 12 deletions(-) create mode 100644 src/pi/piAgentMemoryLoop.test.ts diff --git a/src/contracts/organizationRuntimeContract.ts b/src/contracts/organizationRuntimeContract.ts index 1aaa180..cc127a2 100644 --- a/src/contracts/organizationRuntimeContract.ts +++ b/src/contracts/organizationRuntimeContract.ts @@ -17,6 +17,11 @@ const PRODUCTION_TOOL_PROPERTIES = { } } }, moltnet: { type: "object", additionalProperties: false, required: ["cliPath", "configPath", "networks"], properties: { cliPath: { type: "string", pattern: "^/" }, configPath: { type: "string", pattern: "^/" }, networks: { type: "array", maxItems: 16, items: { type: "object", additionalProperties: false, required: ["id", "rooms", "dms"], properties: { id: { type: "string", minLength: 1 }, rooms: { type: "array", uniqueItems: true, items: { type: "string", minLength: 1 } }, dms: { type: "boolean" } } } } + } }, + memory: { type: "object", additionalProperties: false, required: ["runtimeHomePath"], properties: { + runtimeHomePath: { type: "string", pattern: "^/", maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS }, + source: { type: "string", minLength: 1, maxLength: ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS, pattern: "\\S" }, + tokenBudget: { type: "integer", minimum: 1, maximum: 1000000 } } } } as const; diff --git a/src/pi/piAgentHandle.ts b/src/pi/piAgentHandle.ts index 5593f56..e57110d 100644 --- a/src/pi/piAgentHandle.ts +++ b/src/pi/piAgentHandle.ts @@ -143,7 +143,7 @@ export class PiAgentHandle implements AgentHandle { ? undefined : createPiWorldTrajectoryCapture(); const request = { - eventId: event.id, + eventId: /^(simfile|moltnet|mneme|daimon):.+$/u.test(event.id) ? event.id : `daimon:${event.id}`, kind: event.kind, text: safeWakeText, from: event.from, @@ -242,6 +242,11 @@ export class PiAgentHandle implements AgentHandle { turnId: event.id } satisfies StampTurnOutputCompletedInput); + if (this.memory !== undefined && prepared !== undefined) { + await this.memory.recordTurn({ principal: prepared.principal, prompt: prepared.packet, request, recall: prepared.recall, result: "completed", outputText, toolEvents: tools }) + .catch((recordError) => { this.lastError = `memory record failed: ${recordError instanceof Error ? recordError.message : String(recordError)}`; }); + } + await this.persistTrace({ agentId: this.id, enginePromptMs, @@ -323,6 +328,9 @@ export class PiAgentHandle implements AgentHandle { ? {} : { worldContextBound: worldContext !== undefined }) }).catch(() => undefined); + if (this.memory !== undefined && prepared !== undefined) { + await this.memory.recordTurn({ principal: prepared.principal, prompt: prepared.packet, request, recall: prepared.recall, result: "failed", error: message, outputText: chunks.join("\n").trim(), toolEvents: tools }).catch(() => undefined); + } const persistRawCapture = !rawTrainingCapturePersistAttempted && selectedSession !== undefined; if (persistRawCapture && rawTrainingCapture !== undefined) { diff --git a/src/pi/piAgentMemoryLoop.test.ts b/src/pi/piAgentMemoryLoop.test.ts new file mode 100644 index 0000000..d93e229 --- /dev/null +++ b/src/pi/piAgentMemoryLoop.test.ts @@ -0,0 +1,216 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createMemoryRuntime, JsonlMemoryStore, type MemoryRuntime } from "@noopolis/mneme"; +import type { WakeEvent } from "../core/types.js"; +import { PiAgentHandle, type PiSessionLike } from "./piAgentHandle.js"; +import type { PiTurnTraceModel } from "./turnTrace.js"; + +type PiEvent = { type: string; message?: { content?: string } }; +type Listener = (event: PiEvent) => void; + +const tempRoots: string[] = []; + +test.beforeEach(() => { + process.env.NOOPOLIS_RUN_ID = "run-test-pi-agent-memory-loop"; +}); +test.afterEach(() => { + delete process.env.NOOPOLIS_RUN_ID; +}); + +const tempDir = async (): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), "noopolis-daimon-pi-memory-loop-")); + tempRoots.push(directory); + return directory; +}; + +test.afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +const traceModel: PiTurnTraceModel = { authMethod: "none", model: "test", provider: "test" }; + +const wake = (id: string, text: string): WakeEvent => ({ id, kind: "manual", from: "operator", text }); + +interface StubSession extends PiSessionLike { + prompts: string[]; +} + +/** Stub Pi session recording every prompt it receives and emitting a scripted reply. */ +const makeStubSession = ( + replyFor: (promptIndex: number, text: string) => string, + options: { failOn?: number } = {} +): StubSession => { + const prompts: string[] = []; + const listeners = new Set(); + return { + prompts, + subscribe(listener) { + listeners.add(listener as Listener); + return () => listeners.delete(listener as Listener); + }, + async prompt(text) { + const index = prompts.length; + prompts.push(text); + if (options.failOn === index) { + throw new Error(`stub session failure on prompt ${index}`); + } + const reply = replyFor(index, text); + for (const listener of listeners) { + listener({ type: "turn_end", message: { content: reply } }); + } + }, + dispose() { + listeners.clear(); + } + }; +}; + +test("second wake's prompt carries content recorded from the first wake (write-back loop closes)", async () => { + const root = await tempDir(); + const runtimeHomePath = path.join(root, "runtime"); + const memory: MemoryRuntime = createMemoryRuntime({ + agentId: "loop-agent", + runtimeHomePath, + source: "test", + tokenBudget: 4000 + }); + + const marker = "GALAXY_BRAIN_MARKER_7421"; + const session = makeStubSession((index) => (index === 0 ? `Noted: ${marker}` : "ack-2")); + const handle = new PiAgentHandle( + "loop-agent", + session, + async () => session, + runtimeHomePath, + traceModel, + memory + ); + + await handle.wake(wake("daimon:wake-1", `Remember this for later: ${marker}`)); + await handle.wake(wake("daimon:wake-2", "What did I tell you before?")); + + assert.equal(session.prompts.length, 2); + assert.ok( + session.prompts[1]?.includes(marker), + `expected second wake's prompt to include marker recorded from the first turn, got: ${session.prompts[1]}` + ); + + await handle.stop(); +}); + +test("a failed turn still writes a result:failed memory record, and the original error still propagates", async () => { + const root = await tempDir(); + const runtimeHomePath = path.join(root, "runtime"); + const memory: MemoryRuntime = createMemoryRuntime({ + agentId: "fail-agent", + runtimeHomePath, + source: "test", + tokenBudget: 2000 + }); + + const session = makeStubSession(() => "unused", { failOn: 0 }); + const handle = new PiAgentHandle( + "fail-agent", + session, + async () => session, + runtimeHomePath, + traceModel, + memory + ); + + await assert.rejects( + handle.wake(wake("daimon:wake-fail", "This turn will fail.")), + /stub session failure on prompt 0/u + ); + + const events = await new JsonlMemoryStore(runtimeHomePath).read({ principalAgentId: "fail-agent" }); + const failureRecord = events.find((event) => + event.content.kind === "text" && event.content.text.includes("Turn failed:")); + assert.ok(failureRecord, "expected a failed-turn memory.denied record"); + assert.ok( + failureRecord?.content.kind === "text" && + failureRecord.content.text.includes("stub session failure on prompt 0") + ); + + await handle.stop(); +}); + +test("a rejecting recordTurn does not fail an otherwise-successful turn, but surfaces via status().lastError", async () => { + const root = await tempDir(); + const runtimeHomePath = path.join(root, "runtime"); + const memory: MemoryRuntime = createMemoryRuntime({ + agentId: "record-fail-agent", + runtimeHomePath, + source: "test", + tokenBudget: 2000 + }); + memory.recordTurn = async () => { + throw new Error("record boom"); + }; + + const session = makeStubSession(() => "ack"); + const handle = new PiAgentHandle( + "record-fail-agent", + session, + async () => session, + runtimeHomePath, + traceModel, + memory + ); + + const result = await handle.wake(wake("daimon:wake-record-fail", "This turn should still succeed.")); + assert.equal(result.text, "ack"); + assert.equal(handle.status().lastError, "memory record failed: record boom"); + + await handle.stop(); +}); + +test("a wake whose event.id is not namespaced is still recorded, via the daimon: prefix satisfying mneme's guard", async () => { + const root = await tempDir(); + const runtimeHomePath = path.join(root, "runtime"); + const memory: MemoryRuntime = createMemoryRuntime({ + agentId: "prefix-agent", + runtimeHomePath, + source: "test", + tokenBudget: 2000 + }); + + const session = makeStubSession(() => "ack"); + const handle = new PiAgentHandle( + "prefix-agent", + session, + async () => session, + runtimeHomePath, + traceModel, + memory + ); + + // "custom:wake-1" (colon present, but not one of mneme's four namespaces) + // rather than a bare "wake-1": a bare id with no colon at all also trips a + // separate, more generic causal-event-id shape check inside mneme's own + // prepareTurn. Note: because PiAgentHandle passes the SAME request object + // to both prepareTurn and recordTurn, prepareTurn's own memory.recall.mode + // stamp validates wake_event_id against the identical + // /^(simfile|moltnet|mneme|daimon):.+$/ pattern recordTurn's guard uses — + // so a mutated (unprefixed) request.eventId is always caught by + // prepareTurn first, never by recordTurn's own distinct error message. + const result = await handle.wake(wake("custom:wake-1", "unnamespaced wake id")); + assert.equal(result.text, "ack"); + assert.equal( + handle.status().lastError, + undefined, + "recordTurn should succeed once the raw event.id is namespaced with daimon:" + ); + + const events = await new JsonlMemoryStore(runtimeHomePath).read({ principalAgentId: "prefix-agent" }); + assert.ok( + events.some((event) => event.content.kind === "text" && event.content.text.includes("daimon:custom:wake-1")), + "expected the recorded memory.claimed event to reference the daimon:-prefixed request event id" + ); + + await handle.stop(); +}); diff --git a/src/pi/piHarnessMemory.test.ts b/src/pi/piHarnessMemory.test.ts index 0d01aa1..1c4acd0 100644 --- a/src/pi/piHarnessMemory.test.ts +++ b/src/pi/piHarnessMemory.test.ts @@ -95,7 +95,7 @@ test("non-memory Pi tool events are not implicitly written to memory", async () await handle.stop(); }); -test("failed wakes do not implicitly record recalled memory provenance", async () => { +test("failed wakes still write back recalled memory provenance via recordTurn, and torn-down tool context stays inert", async () => { const root = await tempDir(); const runtimeHomePath = path.join(root, "runtime"); const workspacePath = path.join(root, "workspace"); @@ -168,6 +168,10 @@ test("failed wakes do not implicitly record recalled memory provenance", async ( text: "Use the phoenix memory before failing." }), /prompt failed after recall/u); + // recordTurn now runs on the failure path too (PiAgentHandle.runWake write-back), + // so the recall Mneme already gathered into prepared.recall before the engine + // prompt failed is exactly what gets persisted as memory.recalled provenance — + // that is the intended fix, not a leak. const recalled = await new JsonlMemoryStore(runtimeHomePath).read({ principalAgentId: "mapper", types: ["memory.recalled"] @@ -175,7 +179,7 @@ test("failed wakes do not implicitly record recalled memory provenance", async ( assert.equal(recalled.some((event) => event.content.kind === "text" && event.content.text.includes("PHOENIX_FAIL_MARKER") - ), false); + ), true); assert.ok(searchAfterFailure); await assert.rejects( searchAfterFailure.execute("late-failed-call", { scope: "current", query: "PHOENIX_FAIL_MARKER" }), diff --git a/src/pi/piHarnessSharedMemory.test.ts b/src/pi/piHarnessSharedMemory.test.ts index af4cf3d..2aa1d29 100644 --- a/src/pi/piHarnessSharedMemory.test.ts +++ b/src/pi/piHarnessSharedMemory.test.ts @@ -169,7 +169,10 @@ test("shares one Mneme bank across agents with separate Pi runtimes", async () = const sharedEvents = await sharedBank.read({}); const agentIds = new Set(sharedEvents.map((event) => event.principal?.agentId)); assert.ok(agentIds.has("mapper")); - assert.equal(agentIds.has("listener"), false); + // recordTurn now runs on every wake (PiAgentHandle write-back), so listener's + // own wake also writes its own turn record into the shared bank alongside + // mapper's — that is the intended fix, not cross-tenant leakage. + assert.ok(agentIds.has("listener")); assert.ok(JSON.stringify(sharedEvents).includes("BANK_SHARED_SCOPE_ALPHA")); await listener.stop(); diff --git a/src/runtime/engineDispatcher.test.ts b/src/runtime/engineDispatcher.test.ts index dd7f8f8..8b811b7 100644 --- a/src/runtime/engineDispatcher.test.ts +++ b/src/runtime/engineDispatcher.test.ts @@ -43,7 +43,7 @@ test("production dispatcher starts each closed engine intent through Daimon", as const root = await mkdtemp(path.join(os.tmpdir(), "daimon-dispatcher-")); const priorPath = process.env.PATH; const priorRun = process.env.NOOPOLIS_RUN_ID; - const stub = `#!/usr/bin/env node\nconst args = process.argv.slice(2); const text = process.env.DAIMON_DISPATCH_CONTROL ?? "absent"; const stream = (value) => [{ type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: value }] } }, { type: "result", subtype: "success", is_error: false, result: value, stop_reason: "end_turn", session_id: "fake" }].map(JSON.stringify).join("\\n"); if (args.includes("mcp")) process.stdout.write("ok"); else process.stdout.write(args.includes("--single") ? stream(text) : text);`; + const stub = `#!/usr/bin/env node\nconst args = process.argv.slice(2); const text = process.env.DAIMON_DISPATCH_CONTROL ?? "absent"; const stream = (value) => [{ type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: value }] } }, { type: "result", subtype: "success", is_error: false, result: value, stop_reason: "end_turn", session_id: "fake" }].map(JSON.stringify).join("\\n"); const agy = (value) => JSON.stringify({ event: "result", result: { conversation_id: "fake", status: "SUCCESS", response: value, num_turns: 1, usage: { input_tokens: 11, output_tokens: 2, thinking_tokens: 1, cache_read_tokens: 0, total_tokens: 13 } } }); if (args.includes("mcp")) process.stdout.write("ok"); else process.stdout.write(args.includes("--single") ? stream(text) : args.includes("--output-format") ? agy(text) : text);`; try { for (const name of ["codex", "grok", "agy"]) { const file = path.join(root, name); @@ -70,6 +70,39 @@ test("production dispatcher starts each closed engine intent through Daimon", as } }); +test("engine dispatcher threads a declared memory bank into the Pi harness", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-dispatcher-memory-")); + const priorPath = process.env.PATH; + const priorRun = process.env.NOOPOLIS_RUN_ID; + const stub = `#!/usr/bin/env node\nconst args = process.argv.slice(2); const text = process.env.DAIMON_DISPATCH_CONTROL ?? "absent"; const stream = (value) => [{ type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: value }] } }, { type: "result", subtype: "success", is_error: false, result: value, stop_reason: "end_turn", session_id: "fake" }].map(JSON.stringify).join("\\n"); const agy = (value) => JSON.stringify({ event: "result", result: { conversation_id: "fake", status: "SUCCESS", response: value, num_turns: 1, usage: { input_tokens: 11, output_tokens: 2, thinking_tokens: 1, cache_read_tokens: 0, total_tokens: 13 } } }); if (args.includes("mcp")) process.stdout.write("ok"); else process.stdout.write(args.includes("--single") ? stream(text) : args.includes("--output-format") ? agy(text) : text);`; + try { + const file = path.join(root, "codex"); + await writeFile(file, stub); + await chmod(file, 0o700); + await seedAuth(root, "codex"); + process.env.PATH = `${root}${path.delimiter}${priorPath ?? ""}`; + process.env.NOOPOLIS_RUN_ID = "dispatcher-memory-test"; + process.env.DAIMON_DISPATCH_CONTROL = "host-only"; + const memoryRuntimeHomePath = path.join(root, "memory-bank"); + const config: OrganizationRuntimeAgentConfig = { ...rootConfig(root, "codex"), memory: { runtimeHomePath: memoryRuntimeHomePath, tokenBudget: 500 } }; + const handle = await startOrganizationRuntimeEngine(config, "DAIMON_DISPATCH_CONTROL"); + await handle.wake({ id: "memory-wake", kind: "manual", text: "probe" }); + await handle.stop(); + // createMemoryRuntime (in-process, via PiHarnessAdapter) provisions this + // SQLite index synchronously at the *configured* memory.runtimeHomePath, + // not the agent's own runtimeHomePath — the observable proof the option + // actually reached the harness rather than being silently dropped. + await access(path.join(memoryRuntimeHomePath, "memory", "index.sqlite")); + } finally { + if (priorPath === undefined) delete process.env.PATH; + else process.env.PATH = priorPath; + if (priorRun === undefined) delete process.env.NOOPOLIS_RUN_ID; + else process.env.NOOPOLIS_RUN_ID = priorRun; + delete process.env.DAIMON_DISPATCH_CONTROL; + await rm(root, { recursive: true, force: true }); + } +}); + test("production dispatcher waits for active engine quiescence during shutdown", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-dispatcher-stop-")); const priorPath = process.env.PATH; @@ -141,7 +174,7 @@ test("Daimon frames one escaped identity envelope for every production engine", "const stream = (value) => [{ type: 'assistant', parent_tool_use_id: null, session_id: 'fake', message: { role: 'assistant', stop_reason: 'end_turn', content: [{ type: 'text', text: value }] } }, { type: 'result', subtype: 'success', is_error: false, result: value, stop_reason: 'end_turn', session_id: 'fake' }].map(JSON.stringify).join('\\n');", "if (args.includes('mcp')) process.stdout.write('ok');", "else if (args.includes('--single')) { const text = args[args.indexOf('--single') + 1]; process.stdout.write(stream(text)); }", - "else if (args.includes('--print')) process.stdout.write(args[args.indexOf('--print') + 1]);", + "else if (args.includes('--print')) process.stdout.write(JSON.stringify({ event: 'result', result: { conversation_id: 'fake', status: 'SUCCESS', response: args[args.indexOf('--print') + 1], num_turns: 1 } }));", "else { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); process.stdout.write(Buffer.concat(chunks).toString('utf8')); }" ].join("\n"); try { diff --git a/src/runtime/engineDispatcher.ts b/src/runtime/engineDispatcher.ts index 0c13a73..63b6fc5 100644 --- a/src/runtime/engineDispatcher.ts +++ b/src/runtime/engineDispatcher.ts @@ -1,7 +1,7 @@ import path from "node:path"; import type { AgentHandle } from "../core/types.js"; -import { createCliSessionFactory } from "../pi/cliSession.js"; +import { AGY_MAX_TOOL_TURNS, createCliSessionFactory } from "../pi/cliSession.js"; import { GROK_DAIMON_SANDBOX_PROFILE, prepareAndVerifyGrokSandbox @@ -14,6 +14,7 @@ import { engineHomeName, prepareEngineExecutable, prepareEngineReadiness, readPo import type { EngineBrokerTurnClient } from "./engineBrokerControlClient.js"; import { createProductionAgentTools } from "./productionAgentTools.js"; import { AGY_SUBSCRIPTION_REALM, GROK_SUBSCRIPTION_REALM } from "./contractManifest.js"; +import { recordTurnUsage, resolveTurnUsageLedgerPath } from "./turnUsageLedger.js"; /** * The production-only bridge from a closed runtime engine intent to Daimon's @@ -87,7 +88,11 @@ function adapterFor(agent: OrganizationRuntimeAgentConfig, controlTokenEnv: stri const engine = agent.engine.kind; const sessionFactory = createCliSessionFactory( engine === "agy" - ? { engine, maxToolTurns: 1, timeoutMs: 180_000, toolAccess: "none", dbusSessionBusAddress: agyBusAddress, redactedEnvironmentNames: [controlTokenEnv], identityPrompt: identityEnvelope(agent), command: executablePath, engineHomePath, verifyExecutable, verifyRuntimePaths } + ? { engine, maxToolTurns: AGY_MAX_TOOL_TURNS, timeoutMs: 180_000, dbusSessionBusAddress: agyBusAddress, redactedEnvironmentNames: [controlTokenEnv], identityPrompt: identityEnvelope(agent), command: executablePath, engineHomePath, verifyExecutable, verifyRuntimePaths, + // AGY has no broker to meter it, so the session hands its decoded + // terminal-frame usage straight to the same ledger the Grok broker + // appends to. `recordTurnUsage` is advisory and never rejects. + onTurnUsage: (usage) => recordTurnUsage(resolveTurnUsageLedgerPath(), { agent: agent.id, wake: wakeEnvironmentContext.current ?? "wake", engine: "agy", usage }) } : { engine, redactedEnvironmentNames: [controlTokenEnv], identityPrompt: identityEnvelope(agent), command: executablePath, engineHomePath, verifyExecutable, verifyRuntimePaths, ...(engine==="grok"&&grokBroker!==undefined?{}:{credentialSecretValues: () => readPortableEngineCredentialSecrets(agent.id, engine, engineHomePath)}), ...(engine==="grok"&&grokBroker!==undefined?{grokBrokerTurn:(prompt:string,endpoint:string,signal:AbortSignal)=>grokBroker.turn(agent.id,wakeEnvironmentContext.current??"wake",prompt,endpoint,signal)}:{}), @@ -131,6 +136,11 @@ function cliHarness( sessionFactory, protectedEnvironmentNames, productionTools, - wakeEnvironmentContext + wakeEnvironmentContext, + ...(agent.memory === undefined ? {} : { memory: { + runtimeHomePath: agent.memory.runtimeHomePath, + ...(agent.memory.source === undefined ? {} : { source: agent.memory.source }), + ...(agent.memory.tokenBudget === undefined ? {} : { tokenBudget: agent.memory.tokenBudget }) + } }) }); } diff --git a/src/runtime/organizationRuntime.test.ts b/src/runtime/organizationRuntime.test.ts index f486ecf..ceff5bd 100644 --- a/src/runtime/organizationRuntime.test.ts +++ b/src/runtime/organizationRuntime.test.ts @@ -210,6 +210,70 @@ test("accepts only Daimon's three production engines", () => { assert.throws(() => parseOrganizationRuntimeConfig(injected), /exactly/); }); +const withMemory = (agent: Record, memory: unknown): Record => ({ ...agent, memory }); + +test("parses a declared memory bank and round-trips its fields", () => { + const config = valid() as Record & { agents: Record[] }; + config.agents[0] = withMemory(config.agents[0]!, { runtimeHomePath: "/runtime/memory/editor", source: "editor-notes", tokenBudget: 4000 }); + const parsed = parseOrganizationRuntimeConfig(config); + assert.deepEqual(parsed.agents[0]?.memory, { runtimeHomePath: "/runtime/memory/editor", source: "editor-notes", tokenBudget: 4000 }); +}); + +test("parses a memory bank with only runtimeHomePath and omits absent optional keys", () => { + const config = valid() as Record & { agents: Record[] }; + config.agents[0] = withMemory(config.agents[0]!, { runtimeHomePath: "/runtime/memory/editor" }); + const parsed = parseOrganizationRuntimeConfig(config); + assert.deepEqual(parsed.agents[0]?.memory, { runtimeHomePath: "/runtime/memory/editor" }); + assert.equal(Object.hasOwn(parsed.agents[0]?.memory ?? {}, "source"), false); + assert.equal(Object.hasOwn(parsed.agents[0]?.memory ?? {}, "tokenBudget"), false); +}); + +test("rejects malformed memory declarations", () => { + const relative = valid() as Record & { agents: Record[] }; + relative.agents[0] = withMemory(relative.agents[0]!, { runtimeHomePath: "memory/editor" }); + assert.throws(() => parseOrganizationRuntimeConfig(relative), /absolute POSIX/); + + const unknownKey = valid() as Record & { agents: Record[] }; + unknownKey.agents[0] = withMemory(unknownKey.agents[0]!, { runtimeHomePath: "/runtime/memory/editor", extra: "nope" }); + assert.throws(() => parseOrganizationRuntimeConfig(unknownKey), /invalid fields/); + + for (const tokenBudget of [0, 1_000_001, 1.5]) { + const config = valid() as Record & { agents: Record[] }; + config.agents[0] = withMemory(config.agents[0]!, { runtimeHomePath: "/runtime/memory/editor", tokenBudget }); + assert.throws(() => parseOrganizationRuntimeConfig(config), /tokenBudget must be an integer between 1 and 1000000/); + } +}); + +test("rejects a declared memory bank overlapping another agent's roots", () => { + for (const value of ["/runtime/homes/editor", "/runtime/workspaces/editor", "/runtime/workspaces/editor/nested"]) { + const config = valid() as Record & { agents: Record[] }; + config.agents.push(withMemory({ ...config.agents[0]!, id: "other", workspacePath: "/runtime/workspaces/other", runtimeHomePath: "/runtime/homes/other" }, { runtimeHomePath: value })); + assert.throws(() => parseOrganizationRuntimeConfig(config), /must not overlap/); + } +}); + +test("accepts a declared memory bank nested inside its own agent's runtimeHomePath", () => { + const config = valid() as Record & { agents: Record[] }; + config.agents[0] = withMemory(config.agents[0]!, { runtimeHomePath: "/runtime/homes/editor/memory" }); + const parsed = parseOrganizationRuntimeConfig(config); + assert.equal(parsed.agents[0]?.memory?.runtimeHomePath, "/runtime/homes/editor/memory"); +}); + +test("rejects a declared memory bank nested inside its own agent's workspacePath", () => { + const config = valid() as Record & { agents: Record[] }; + config.agents[0] = withMemory(config.agents[0]!, { runtimeHomePath: "/runtime/workspaces/editor/memory" }); + assert.throws(() => parseOrganizationRuntimeConfig(config), /must not overlap/); +}); + +test("accepts two agents sharing one identical declared memory bank", () => { + const config = valid() as Record & { agents: Record[] }; + config.agents[0] = withMemory(config.agents[0]!, { runtimeHomePath: "/runtime/memory/shared" }); + config.agents.push(withMemory({ ...config.agents[0]!, id: "other", workspacePath: "/runtime/workspaces/other", runtimeHomePath: "/runtime/homes/other" }, { runtimeHomePath: "/runtime/memory/shared" })); + const parsed = parseOrganizationRuntimeConfig(config); + assert.equal(parsed.agents[0]?.memory?.runtimeHomePath, "/runtime/memory/shared"); + assert.equal(parsed.agents[1]?.memory?.runtimeHomePath, "/runtime/memory/shared"); +}); + test("wake-result status and code pairs are fixed", () => { const fixtures = [ { version: "noopolis.daimon.wake-result.v1", status: "completed", agentId: "a", wakeId: "w", text: "done", durationMs: 1 }, diff --git a/src/runtime/organizationRuntime.ts b/src/runtime/organizationRuntime.ts index c9247de..9499fc6 100644 --- a/src/runtime/organizationRuntime.ts +++ b/src/runtime/organizationRuntime.ts @@ -28,11 +28,12 @@ export type OrganizationRuntimeEngineKind = "codex" | "grok" | "agy"; export type OrganizationRuntimeEngineIntent = Readonly<{ kind: OrganizationRuntimeEngineKind }>; export type OrganizationRuntimeMcpServer = Readonly<{ name: string; transport: "stdio" | "sse" | "streamable_http"; command?: string; args: readonly string[]; env: Readonly>; authSecretEnv?: string; url?: string; tools: readonly string[] }>; export type OrganizationRuntimeMoltnet = Readonly<{ cliPath: string; configPath: string; networks: readonly Readonly<{ id: string; rooms: readonly string[]; dms: boolean }>[] }>; +export type OrganizationRuntimeMemory = Readonly<{ runtimeHomePath: string; source?: string; tokenBudget?: number }>; export type OrganizationRuntimeSchedule = | Readonly<{ kind: "disabled" }> | Readonly<{ kind: "every"; interval_ms: number; prompt: string }> | Readonly<{ kind: "cron"; cron: string; timezone: string; prompt: string }>; -export type OrganizationRuntimeAgentConfig = Readonly<{ id: string; name: string; instructions: string; workspacePath: string; runtimeHomePath: string; engine: OrganizationRuntimeEngineIntent; schedule?: OrganizationRuntimeSchedule; mcp?: readonly OrganizationRuntimeMcpServer[]; moltnet?: OrganizationRuntimeMoltnet }>; +export type OrganizationRuntimeAgentConfig = Readonly<{ id: string; name: string; instructions: string; workspacePath: string; runtimeHomePath: string; engine: OrganizationRuntimeEngineIntent; schedule?: OrganizationRuntimeSchedule; mcp?: readonly OrganizationRuntimeMcpServer[]; moltnet?: OrganizationRuntimeMoltnet; memory?: OrganizationRuntimeMemory }>; export type OrganizationRuntimeHostConfig = Readonly<{ bindHost: string; port: number; /** Variable name only; never secret configuration data. */ controlTokenEnv: string }>; export type OrganizationRuntimeConfig = Readonly<{ version: typeof ORGANIZATION_RUNTIME_VERSION | typeof ORGANIZATION_RUNTIME_V2_VERSION; host: OrganizationRuntimeHostConfig; agents: readonly OrganizationRuntimeAgentConfig[] }>; export type OrganizationRuntimeLifecycleState = "starting" | "running" | "stopping" | "stopped"; diff --git a/src/runtime/organizationRuntimeParsing.ts b/src/runtime/organizationRuntimeParsing.ts index 3f7970e..33472aa 100644 --- a/src/runtime/organizationRuntimeParsing.ts +++ b/src/runtime/organizationRuntimeParsing.ts @@ -42,6 +42,7 @@ export function parseOrganizationRuntimeConfig(value: unknown): OrganizationRunt return agent; }); isolated(agents); + isolatedMemory(agents); return { version: version as OrganizationRuntimeConfig["version"], host, agents }; } @@ -66,12 +67,13 @@ export const isOrganizationRuntimeConfig = validateOrganizationRuntimeConfig; function parseAgent(value: unknown, label: string, v2: boolean): OrganizationRuntimeAgentConfig { const agent = object(value, label); - exactOptional(agent, v2 ? ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine", "schedule"] : ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine"], ["mcp", "moltnet"], label); + exactOptional(agent, v2 ? ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine", "schedule"] : ["id", "name", "instructions", "workspacePath", "runtimeHomePath", "engine"], ["mcp", "moltnet", "memory"], label); return { id: nonEmpty(agent.id, `${label}.id`), name: nonEmpty(agent.name, `${label}.name`), instructions: nonEmpty(agent.instructions, `${label}.instructions`), workspacePath: absolute(agent.workspacePath, `${label}.workspacePath`), runtimeHomePath: absolute(agent.runtimeHomePath, `${label}.runtimeHomePath`), engine: engine(agent.engine, `${label}.engine`), ...(v2 ? { schedule: schedule(agent.schedule, `${label}.schedule`) } : {}), ...(agent.mcp === undefined ? {} : { mcp: mcpServers(agent.mcp, `${label}.mcp`) }), - ...(agent.moltnet === undefined ? {} : { moltnet: moltnet(agent.moltnet, `${label}.moltnet`) }) + ...(agent.moltnet === undefined ? {} : { moltnet: moltnet(agent.moltnet, `${label}.moltnet`) }), + ...(agent.memory === undefined ? {} : { memory: memory(agent.memory, `${label}.memory`) }) }; } @@ -99,6 +101,20 @@ function moltnet(value: unknown, label: string): NonNullable { + const item = object(value, label); exactOptional(item, ["runtimeHomePath"], ["source", "tokenBudget"], label); + return { + runtimeHomePath: absolute(item.runtimeHomePath, `${label}.runtimeHomePath`), + ...(item.source === undefined ? {} : { source: nonEmpty(item.source, `${label}.source`) }), + ...(item.tokenBudget === undefined ? {} : { tokenBudget: tokenBudget(item.tokenBudget, `${label}.tokenBudget`) }) + }; +} + +function tokenBudget(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 1_000_000) throw new TypeError(`${label} must be an integer between 1 and 1000000`); + return value; +} + function schedule(value: unknown, label: string): OrganizationRuntimeSchedule { const input = object(value, label); const kind = string(input.kind, `${label}.kind`); if (kind === "disabled") { exact(input, ["kind"], label); return { kind }; } @@ -202,6 +218,26 @@ function array(value: unknown, label: string): readonly unknown[] { if (!Array.i function exact(value: RecordValue, expected: readonly string[], label: string): void { const extras = Object.keys(value).filter((key) => !expected.includes(key)); const missing = expected.filter((key) => !Object.hasOwn(value, key)); if (extras.length || missing.length) throw new TypeError(`${label} must contain exactly ${expected.join(", ")}`); } function exactOptional(value: RecordValue, required: readonly string[], optional: readonly string[], label: string): void { const extras = Object.keys(value).filter((key) => !required.includes(key) && !optional.includes(key)); const missing = required.filter((key) => !Object.hasOwn(value, key)); if (extras.length || missing.length) throw new TypeError(`${label} has invalid fields`); } function isolated(agents: readonly OrganizationRuntimeAgentConfig[]): void { const paths = agents.flatMap((agent) => [{ agentId: agent.id, kind: "workspacePath", value: agent.workspacePath }, { agentId: agent.id, kind: "runtimeHomePath", value: agent.runtimeHomePath }]); for (let left = 0; left < paths.length; left += 1) for (let right = left + 1; right < paths.length; right += 1) { const first = paths[left]!; const second = paths[right]!; if (first.value === second.value || first.value.startsWith(`${second.value}/`) || second.value.startsWith(`${first.value}/`)) throw new TypeError(`agents ${first.agentId}.${first.kind} and ${second.agentId}.${second.kind} must not overlap`); } } +/** + * A declared bank may sit inside its own agent's runtime home, or be shared + * verbatim by another agent's declared bank; it must never reach into another + * agent's private roots, and it must never sit inside its own agent's + * model-writable workspace (the bash tool's cwd), which would let the sandboxed + * model tamper with its own ledger and bypass Mneme's policy layer entirely. + */ +function isolatedMemory(agents: readonly OrganizationRuntimeAgentConfig[]): void { + for (const agent of agents) { + if (agent.memory === undefined) continue; + const value = agent.memory.runtimeHomePath; + if (value === agent.workspacePath || value.startsWith(`${agent.workspacePath}/`) || agent.workspacePath.startsWith(`${value}/`)) throw new TypeError(`agent ${agent.id}.memory.runtimeHomePath and ${agent.id}.workspacePath must not overlap`); + for (const peer of agents) { + if (peer.id === agent.id) continue; + for (const [kind, peerValue] of [["workspacePath", peer.workspacePath], ["runtimeHomePath", peer.runtimeHomePath]] as const) { + if (value === peerValue || value.startsWith(`${peerValue}/`) || peerValue.startsWith(`${value}/`)) throw new TypeError(`agent ${agent.id}.memory.runtimeHomePath and ${peer.id}.${kind} must not overlap`); + } + } + } +} function string(value: unknown, label: string): string { if (typeof value !== "string") throw new TypeError(`${label} must be a string`); if (Buffer.byteLength(value, "utf8") > ORGANIZATION_RUNTIME_MAX_STRING_BYTES || Array.from(value).length > ORGANIZATION_RUNTIME_MAX_STRING_CODEPOINTS) throw new TypeError(`${label} exceeds the runtime string limit`); return value; } function nonEmpty(value: unknown, label: string): string { const result = string(value, label); if (!result.trim()) throw new TypeError(`${label} must not be empty`); return result; } function absolute(value: unknown, label: string): string { const result = nonEmpty(value, label); if (!path.posix.isAbsolute(result)) throw new TypeError(`${label} must be an absolute POSIX path`); const normalized = path.posix.normalize(result); if (normalized === "/") throw new TypeError(`${label} must not overlap filesystem root`); return normalized.replace(/\/+$/, ""); } From 2c622d6ca51668c6f71297f74f1d69b565c67bfd Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:36:22 +0200 Subject: [PATCH 16/24] feat(usage): record per-turn token accounting to an advisory append-only ledger --- src/pi/fixtures/README.md | 84 ++++++++++ .../agy-stream-json-plain-result.jsonl | 5 + .../agy-stream-json-tool-result.jsonl | 10 ++ ...streaming-messages-json-error-result.jsonl | 2 + ...reaming-messages-json-success-result.jsonl | 3 + src/pi/grokHeadlessResult.ts | 79 +++++++++- src/pi/grokHeadlessTurnUsage.test.ts | 133 ++++++++++++++++ src/runtime/grokEngineBroker.ts | 28 +++- src/runtime/grokEngineBrokerUsage.test.ts | 127 +++++++++++++++ src/runtime/turnUsageLedger.test.ts | 127 +++++++++++++++ src/runtime/turnUsageLedger.ts | 149 ++++++++++++++++++ 11 files changed, 741 insertions(+), 6 deletions(-) create mode 100644 src/pi/fixtures/README.md create mode 100644 src/pi/fixtures/agy-stream-json-plain-result.jsonl create mode 100644 src/pi/fixtures/agy-stream-json-tool-result.jsonl create mode 100644 src/pi/fixtures/grok-streaming-messages-json-error-result.jsonl create mode 100644 src/pi/fixtures/grok-streaming-messages-json-success-result.jsonl create mode 100644 src/pi/grokHeadlessTurnUsage.test.ts create mode 100644 src/runtime/grokEngineBrokerUsage.test.ts create mode 100644 src/runtime/turnUsageLedger.test.ts create mode 100644 src/runtime/turnUsageLedger.ts diff --git a/src/pi/fixtures/README.md b/src/pi/fixtures/README.md new file mode 100644 index 0000000..47c041e --- /dev/null +++ b/src/pi/fixtures/README.md @@ -0,0 +1,84 @@ +# Grok headless result fixtures + +`grok-streaming-messages-json-error-result.jsonl` was captured live on +2026-08-30 from `grok 1.0.13` (macOS arm64) with the exact flags production +uses (`--prompt-file … --output-format streaming-messages-json`). The turn +failed with `402 Payment Required — Grok Build usage balance exhausted`, which +is the incident this ledger exists to make visible, so it is a real +zero-filled-usage frame rather than a hand-written one. + +The `system`/`init` frame's environment fields were sanitized before commit — +`cwd` is `/workspace` and `slash_commands`/`skills` are neutral placeholders — +because the live capture embedded the capturing operator's home path and their +personal skill install. The frame's structure is unchanged (same keys, same +non-empty string arrays), and the decoder reads none of those fields; only the +terminal `result` frame is verbatim. +`grokHeadlessTurnUsage.test.ts`'s "the captured fixtures carry no capturing +machine's environment" case keeps it that way. + +It pins the field names the decoder reads: + + result.usage.input_tokens uncached prompt tokens only + result.usage.output_tokens + result.usage.cache_read_input_tokens + result.usage.cache_creation_input_tokens + result.num_turns + result.total_cost_usd + result.modelUsage `{}` when no per-model breakdown + +`grok-streaming-messages-json-success-result.jsonl` is the success-subtype +`result` frame documented verbatim inside the same binary (`strings` over +`grok-1.0.13-macos-aarch64`, the "Output Formats › streaming-messages-json" +section), with the token buckets filled from the live measurement recorded in +`USAGE_ACCOUNTING_DESIGN.md`. That document also states, normatively: + +- `total_tokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens` +- `usage.input_tokens` is **uncached only**; the three prompt-side buckets are disjoint +- `result.usage` "always emits numeric buckets, even when data is missing … Any + bucket grok cannot account for falls back to `0`, because the Messages API + schema has no marker for incomplete or absent usage. … Read an all-zero + `usage` here as 'unknown', not 'free'." + +That last point is why `complete` is a heuristic in this stream and not a read +signal: the `usage_is_incomplete` flag exists only in the `json` output format, +which production does not use. + + +# AGY headless result fixtures + +`agy-stream-json-plain-result.jsonl` and `agy-stream-json-tool-result.jsonl` +were captured live on 2026-08-30 from the `agy` CLI (macOS arm64) with the +flags production uses (`--print … --output-format stream-json`, the second one +additionally `--dangerously-skip-permissions` with a throwaway stdio MCP server +registered through `agy mcp add`). Both are real turns, not hand-written +frames. + +Sanitization before commit: the capturing operator's scratchpad path was +replaced with `/workspace`, the one absolute path inside a `view_file` tool +frame was rewritten under it, and the two `conversation_id` values were +replaced with fixed placeholders. Nothing the decoder reads was touched — the +terminal `result` frame's `status`, `response`, `num_turns` and `usage` are +verbatim. `agyHeadlessResult.test.ts`'s "the captured fixtures carry no +capturing machine's environment" case keeps it that way. + +They pin the field names the decoder reads, and the two facts that make AGY's +envelope different from Grok's: + + result.status "SUCCESS", not subtype/is_error + result.response the reply text + result.num_turns + result.usage.input_tokens + result.usage.output_tokens + result.usage.cache_read_tokens NOT cache_read_input_tokens + result.usage.thinking_tokens a SUBSET of output_tokens + result.usage.total_tokens cross-checked, not trusted + +- There is no `cache_creation_input_tokens` and no `total_cost_usd`. +- The plain turn is `13,722 + 74 = 13,796 = total_tokens` while + `thinking_tokens` is 73, which is the arithmetic proving reasoning tokens are + already inside `output_tokens`. +- The tool turn's terminal `usage` (44,937 input) is the SUM over the turn's + three model steps (14,579 + 15,079 + 15,279), not the last step's — so a + decoder that read a `step_update` would under-report a tool-using wake by + roughly threefold. One tool call against an empty-schema tool costs ~31k + extra tokens of tool-use preamble. diff --git a/src/pi/fixtures/agy-stream-json-plain-result.jsonl b/src/pi/fixtures/agy-stream-json-plain-result.jsonl new file mode 100644 index 0000000..6081769 --- /dev/null +++ b/src/pi/fixtures/agy-stream-json-plain-result.jsonl @@ -0,0 +1,5 @@ +{"event":"init","conversation_id":"00000000-0000-4000-8000-000000000001","init":{"cwd":"/workspace","tools":["ask_custom_permission","ask_permission","ask_question","browser_click_element","browser_drag_pixel_to_pixel","browser_get_dom","browser_get_network_request","browser_input","browser_list_network_requests","browser_mouse_down","browser_mouse_up","browser_move_mouse","browser_press_key","browser_refresh_page","browser_resize_window","browser_scroll","browser_scroll_dom","browser_select_option","browser_subagent","call_mcp_tool","capture_browser_console_logs","capture_browser_screenshot","click_browser_pixel","command_status","define_subagent","delete_knowledge","execute_browser_javascript","find_by_name","finish","generate_image","grep_search","invoke_subagent","list_browser_pages","list_dir","list_permissions","list_resources","manage_inbox","manage_subagents","manage_task","multi_replace_file_content","notebook_edit","notebook_execution","open_browser_url","read_browser_page","read_resource","read_url_content","replace_file_content","run_command","schedule","search_web","sed_file","send_command_input","send_message","view_file","wait","wait_5_seconds","write_to_file"],"permission_mode":"request-review"}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000001","step_index":0,"state":"DONE","step_type":"user_input"}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000001","step_index":1,"state":"ACTIVE","step_type":"agent_response","text_delta":"ok"}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000001","step_index":1,"state":"DONE","step_type":"agent_response","text_delta":"\n","duration_seconds":1.674863,"usage":{"input_tokens":13722,"output_tokens":74,"thinking_tokens":73,"cache_read_tokens":0,"total_tokens":13796}}} +{"event":"result","result":{"conversation_id":"00000000-0000-4000-8000-000000000001","status":"SUCCESS","response":"ok\n","duration_seconds":1.697396,"num_turns":1,"usage":{"input_tokens":13722,"output_tokens":74,"thinking_tokens":73,"cache_read_tokens":0,"total_tokens":13796}}} diff --git a/src/pi/fixtures/agy-stream-json-tool-result.jsonl b/src/pi/fixtures/agy-stream-json-tool-result.jsonl new file mode 100644 index 0000000..bb661e3 --- /dev/null +++ b/src/pi/fixtures/agy-stream-json-tool-result.jsonl @@ -0,0 +1,10 @@ +{"event":"init","conversation_id":"00000000-0000-4000-8000-000000000002","init":{"cwd":"/workspace","tools":["ask_custom_permission","ask_permission","ask_question","browser_click_element","browser_drag_pixel_to_pixel","browser_get_dom","browser_get_network_request","browser_input","browser_list_network_requests","browser_mouse_down","browser_mouse_up","browser_move_mouse","browser_press_key","browser_refresh_page","browser_resize_window","browser_scroll","browser_scroll_dom","browser_select_option","browser_subagent","call_mcp_tool","capture_browser_console_logs","capture_browser_screenshot","click_browser_pixel","command_status","define_subagent","delete_knowledge","execute_browser_javascript","find_by_name","finish","generate_image","grep_search","invoke_subagent","list_browser_pages","list_dir","list_permissions","list_resources","manage_inbox","manage_subagents","manage_task","multi_replace_file_content","notebook_edit","notebook_execution","open_browser_url","read_browser_page","read_resource","read_url_content","replace_file_content","run_command","schedule","search_web","sed_file","send_command_input","send_message","view_file","wait","wait_5_seconds","write_to_file"],"permission_mode":"always-proceed"}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000002","step_index":0,"state":"DONE","step_type":"user_input"}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000002","step_index":1,"state":"DONE","step_type":"agent_response","duration_seconds":1.4037519999999999,"usage":{"input_tokens":14579,"output_tokens":256,"thinking_tokens":189,"cache_read_tokens":0,"total_tokens":14835}}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000002","step_index":2,"state":"ACTIVE","step_type":"tool","tool_name":"view_file","tool_info":{"name":"view_file","parameters":{"AbsolutePath":"/workspace/.gemini/antigravity-cli/mcp/noopolis_probe/noopolis_probe.json"}}}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000002","step_index":2,"state":"DONE","step_type":"tool","tool_name":"view_file","duration_seconds":0.016519,"tool_info":{"name":"view_file","parameters":{"AbsolutePath":"/workspace/.gemini/antigravity-cli/mcp/noopolis_probe/noopolis_probe.json"},"output":"1 lines, 202 bytes"}}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000002","step_index":3,"state":"DONE","step_type":"agent_response","duration_seconds":1.11998,"usage":{"input_tokens":15079,"output_tokens":112,"thinking_tokens":52,"cache_read_tokens":0,"total_tokens":15191}}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000002","step_index":4,"state":"ACTIVE","step_type":"tool","tool_name":"call_mcp_tool","tool_info":{"name":"call_mcp_tool","parameters":{"Arguments":{},"ServerName":"noopolis_probe","ToolName":"noopolis_probe"}}}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000002","step_index":4,"state":"DONE","step_type":"tool","tool_name":"call_mcp_tool","duration_seconds":0.016956,"tool_info":{"name":"call_mcp_tool","parameters":{"Arguments":{},"ServerName":"noopolis_probe","ToolName":"noopolis_probe"},"output":"GLYPH-TANGERINE-4471"}}} +{"event":"step_update","step_update":{"conversation_id":"00000000-0000-4000-8000-000000000002","step_index":5,"state":"DONE","step_type":"agent_response","text_delta":"GLYPH-TANGERINE-4471\n","duration_seconds":1.129975,"usage":{"input_tokens":15279,"output_tokens":76,"thinking_tokens":64,"cache_read_tokens":0,"total_tokens":15355}}} +{"event":"result","result":{"conversation_id":"00000000-0000-4000-8000-000000000002","status":"SUCCESS","response":"GLYPH-TANGERINE-4471\n","duration_seconds":3.707643,"num_turns":1,"usage":{"input_tokens":44937,"output_tokens":444,"thinking_tokens":305,"cache_read_tokens":0,"total_tokens":45381}}} diff --git a/src/pi/fixtures/grok-streaming-messages-json-error-result.jsonl b/src/pi/fixtures/grok-streaming-messages-json-error-result.jsonl new file mode 100644 index 0000000..23b60d6 --- /dev/null +++ b/src/pi/fixtures/grok-streaming-messages-json-error-result.jsonl @@ -0,0 +1,2 @@ +{"type":"system","subtype":"init","session_id":"01a04fa6-37ca-7cb3-8fed-94b229b86ffe","apiKeySource":"oauth","model":"grok-4.6","cwd":"/workspace","permissionMode":"bypassPermissions","tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","image_gen","image_edit","image_to_video","reference_to_video","write"],"slash_commands":["compact","context","session-info"],"mcp_servers":[],"skills":["example-skill"],"uuid":"728aed44-9689-4c37-a44f-0dfee0d09505"} +{"type":"result","subtype":"error_during_execution","is_error":true,"duration_ms":980,"duration_api_ms":0,"num_turns":0,"stop_reason":null,"total_cost_usd":0.0,"usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"server_tool_use":{"web_search_requests":0}},"modelUsage":{},"errors":["Internal error: {\n \"message\": \"API error (status 402 Payment Required): Grok Build usage balance exhausted\",\n \"http_status\": 402\n}"],"session_id":"01a04fa6-37ca-7cb3-8fed-94b229b86ffe","uuid":"bf2e9055-f2ff-4693-88bd-f5063c93d2a3"} diff --git a/src/pi/fixtures/grok-streaming-messages-json-success-result.jsonl b/src/pi/fixtures/grok-streaming-messages-json-success-result.jsonl new file mode 100644 index 0000000..df04baf --- /dev/null +++ b/src/pi/fixtures/grok-streaming-messages-json-success-result.jsonl @@ -0,0 +1,3 @@ +{"type":"system","subtype":"init","session_id":"01a04fa6-37ca-7cb3-8fed-94b229b86ffe","apiKeySource":"oauth","model":"grok-4.6","cwd":"/workspace","permissionMode":"bypassPermissions","tools":["read_file"],"slash_commands":[],"mcp_servers":[{"name":"daimon","status":"connected"}],"skills":[],"uuid":"728aed44-9689-4c37-a44f-0dfee0d09505"} +{"type":"assistant","message":{"id":"msg_0","type":"message","role":"assistant","model":"grok-4.6","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":8746,"output_tokens":29,"cache_read_input_tokens":5760,"cache_creation_input_tokens":0}},"parent_tool_use_id":null,"session_id":"01a04fa6-37ca-7cb3-8fed-94b229b86ffe","uuid":"1f0b1f3f-1a1a-4a1a-8a1a-1a1a1a1a1a1a"} +{"type":"result","subtype":"success","is_error":false,"duration_ms":1980,"duration_api_ms":1740,"num_turns":1,"result":"OK","stop_reason":"end_turn","total_cost_usd":0.0035,"usage":{"input_tokens":8746,"output_tokens":29,"cache_read_input_tokens":5760,"cache_creation_input_tokens":0,"server_tool_use":{"web_search_requests":0}},"modelUsage":{},"session_id":"01a04fa6-37ca-7cb3-8fed-94b229b86ffe","uuid":"bf2e9055-f2ff-4693-88bd-f5063c93d2a3"} diff --git a/src/pi/grokHeadlessResult.ts b/src/pi/grokHeadlessResult.ts index 33c5363..00cca95 100644 --- a/src/pi/grokHeadlessResult.ts +++ b/src/pi/grokHeadlessResult.ts @@ -10,6 +10,72 @@ const invalidResult = (detail: string): Error => type TerminalAssistant = Readonly<{ sessionId: string; stopReason: string; text: string }>; +/** + * Token accounting for one turn, as the engine itself reported it. + * + * These are subscription-backed CLIs, so nothing here is billed: the counts are + * real quota consumption and `notionalUsd` is what the same turn would have cost + * at metered API rates. Kept away from anything named `cost` so a later reader + * cannot mistake it for a charge. + * + * Field provenance is the `streaming-messages-json` terminal `result` frame, + * which production hardcodes (`grokBrokerWorkerConfig.ts`). Its `result.usage` + * is the Messages API `message.usage` shape with three *disjoint* prompt-side + * buckets, so the full turn cost is + * `input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens`. + */ +export type GrokTurnUsage = Readonly<{ + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + calls: number; + notionalUsd: number; + complete: boolean; +}>; + +export type GrokHeadlessTurn = Readonly<{ text: string; usage?: GrokTurnUsage }>; + +/** + * The four disjoint token buckets grok emits on `result.usage`. All four must be + * present and be non-negative safe integers; a renamed, stringified, negative, + * or fractional field rejects the whole block rather than silently contributing + * a zero, because a zero bucket is byte-indistinguishable from a real one. + */ +const USAGE_TOKEN_FIELDS = ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"] as const; + +const tokenCount = (value: unknown): number | undefined => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; + +const nonNegativeAmount = (value: unknown): number => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; + +/** + * Extract per-turn usage from the terminal `result` frame. + * + * Advisory: a malformed or absent usage block yields `undefined` rather than + * failing a turn that published correctly, and never throws. + * + * `complete` is a stated heuristic, not a wire-observable signal. grok's own + * embedded documentation for this stream says any bucket it cannot account for + * "falls back to `0`, because the Messages API schema has no marker for + * incomplete or absent usage", and instructs consumers to "read an all-zero + * `usage` here as 'unknown', not 'free'". That is exactly the rule below. It + * cannot catch a *partially* zero-filled turn, which sums to a plausible total + * and is stamped `complete: true` while under-counting — so every count this + * decoder produces is a lower bound. + */ +const decodeTurnUsage = (result: JsonRecord): GrokTurnUsage | undefined => { + if (!isRecord(result.usage)) return undefined; + const usage = result.usage; + const counts = USAGE_TOKEN_FIELDS.map((field) => tokenCount(usage[field])); + if (counts.some((count) => count === undefined)) return undefined; + const [input, output, cacheRead, cacheWrite] = counts as [number, number, number, number]; + const total = input + cacheRead + cacheWrite + output; + return { input, output, cacheRead, cacheWrite, total, calls: tokenCount(result.num_turns) ?? 0, notionalUsd: nonNegativeAmount(result.total_cost_usd), complete: total > 0 }; +}; + const decodeTerminalAssistant = (event: JsonRecord): TerminalAssistant | undefined => { if (event.type !== "assistant" || event.parent_tool_use_id !== null) return undefined; if (typeof event.session_id !== "string" || !isRecord(event.message)) throw invalidResult("invalid assistant event"); @@ -31,8 +97,11 @@ const decodeTerminalAssistant = (event: JsonRecord): TerminalAssistant | undefin return { sessionId: event.session_id, stopReason: message.stop_reason, text }; }; -/** Decode Grok's terminal message stream without treating progress messages as a reply. */ -export const decodeGrokHeadlessResult = (output: string): string => { +/** + * Decode Grok's terminal message stream without treating progress messages as a + * reply, and extract the turn's own token accounting from the same frame. + */ +export const decodeGrokHeadlessTurn = (output: string): GrokHeadlessTurn => { const lines = output.split(/\r?\n/).filter((line) => line.length > 0); if (lines.length === 0) throw invalidResult("empty stream"); let finalAssistant: TerminalAssistant | undefined; @@ -61,6 +130,10 @@ export const decodeGrokHeadlessResult = (output: string): string => { finalAssistant.sessionId !== result.session_id || finalAssistant.text !== result.result) { throw invalidResult("terminal result mismatch"); } - return result.result.trim(); + const usage = decodeTurnUsage(result); + return usage === undefined ? { text: result.result.trim() } : { text: result.result.trim(), usage }; }; + +/** Text-only view of {@link decodeGrokHeadlessTurn}, for callers that do not meter. */ +export const decodeGrokHeadlessResult = (output: string): string => decodeGrokHeadlessTurn(output).text; import { asGrokAuthenticationRejected } from "../runtime/grokAuthenticationError.js"; diff --git a/src/pi/grokHeadlessTurnUsage.test.ts b/src/pi/grokHeadlessTurnUsage.test.ts new file mode 100644 index 0000000..fdebe15 --- /dev/null +++ b/src/pi/grokHeadlessTurnUsage.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { decodeGrokHeadlessResult, decodeGrokHeadlessTurn } from "./grokHeadlessResult.js"; + +const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); +const fixture = (name: string): string => readFileSync(path.join(fixtures, name), "utf8"); + +const assistant = (text: string) => ({ + type: "assistant", parent_tool_use_id: null, session_id: "session-1", + message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text }] } +}); +const result = (usage: unknown, extra: Record = {}) => ({ + type: "result", subtype: "success", is_error: false, result: "ACK", stop_reason: "end_turn", session_id: "session-1", + num_turns: 3, total_cost_usd: 0.0035, ...(usage === undefined ? {} : { usage }), ...extra +}); +const stream = (...events: readonly unknown[]): string => events.map((event) => JSON.stringify(event)).join("\n"); +const turn = (usage: unknown, extra: Record = {}) => + decodeGrokHeadlessTurn(stream(assistant("ACK"), result(usage, extra))); + +const fullUsage = { + input_tokens: 8_746, output_tokens: 29, + cache_read_input_tokens: 5_760, cache_creation_input_tokens: 12, + server_tool_use: { web_search_requests: 0 } +}; + +test("the live success fixture decodes to the measured turn, with cache in the total", () => { + const decoded = decodeGrokHeadlessTurn(fixture("grok-streaming-messages-json-success-result.jsonl")); + assert.equal(decoded.text, "OK"); + assert.deepEqual(decoded.usage, { + input: 8_746, output: 29, cacheRead: 5_760, cacheWrite: 0, + // Mutation guard: `total` must include both cache buckets. Dropping them + // yields 8,775 and hides the dominant cost of every wake. + total: 14_535, + calls: 1, notionalUsd: 0.0035, complete: true + }); +}); + +test("the live zero-filled fixture is a lower bound, not a free turn", () => { + // The captured frame is a real `error_during_execution` result: grok's + // Messages-shaped stream has no incompleteness marker, so an all-zero usage + // block reads as "unknown". The decoder still refuses the frame outright, + // because the turn never published — but the usage rule is asserted directly + // below so the heuristic itself is pinned. + assert.throws(() => decodeGrokHeadlessResult(fixture("grok-streaming-messages-json-error-result.jsonl")), /no publishable terminal response/u); + assert.equal(turn({ input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }).usage?.complete, false); + assert.equal(turn({ ...fullUsage, input_tokens: 0 }).usage?.complete, true, "a partially zero-filled turn is byte-indistinguishable and is not detected"); +}); + +test("the captured fixtures carry no capturing machine's environment", () => { + // These frames ship in the repo. The live capture embedded the operator's + // home/scratchpad path and their personal skill and slash-command lists in + // the `system/init` frame; none of it is anything the decoder reads. + for (const name of [ + "grok-streaming-messages-json-error-result.jsonl", + "grok-streaming-messages-json-success-result.jsonl" + ]) { + const text = fixture(name); + assert.doesNotMatch(text, /\/Users\/|\/home\/|\/private\/tmp\//u, `${name} leaks a host path`); + assert.doesNotMatch(text, /claude-\d+|scratchpad/u, `${name} leaks a host scratchpad`); + } + + // The structural shape the parser walks is unchanged: a `system`/`init` + // frame with the same keys, still carrying non-empty string arrays. + const init = JSON.parse(fixture("grok-streaming-messages-json-error-result.jsonl").split("\n")[0]!); + assert.equal(init.type, "system"); + assert.equal(init.subtype, "init"); + assert.equal(init.cwd, "/workspace"); + for (const key of ["tools", "slash_commands", "skills"]) { + assert.ok(Array.isArray(init[key]) && init[key].length > 0, `init.${key} lost its shape`); + assert.ok(init[key].every((entry: unknown) => typeof entry === "string"), `init.${key} lost its shape`); + } +}); + +test("usage sums the four disjoint buckets and carries num_turns and the notional amount", () => { + assert.deepEqual(turn(fullUsage).usage, { + input: 8_746, output: 29, cacheRead: 5_760, cacheWrite: 12, + total: 8_746 + 29 + 5_760 + 12, calls: 3, notionalUsd: 0.0035, complete: true + }); +}); + +test("a malformed, renamed, or stringified usage field yields no usage and never throws", () => { + const rejected: readonly unknown[] = [ + undefined, + null, + "usage", + [], + { ...fullUsage, input_tokens: "8746" }, + { ...fullUsage, output_tokens: -1 }, + { ...fullUsage, cache_read_input_tokens: 1.5 }, + { ...fullUsage, cache_creation_input_tokens: Number.NaN }, + { ...fullUsage, cache_creation_input_tokens: Number.MAX_SAFE_INTEGER + 2 }, + { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 1 }, + { inputTokens: 8_746, outputTokens: 29, cacheReadInputTokens: 5_760, cacheCreationInputTokens: 0 } + ]; + for (const usage of rejected) { + const decoded = turn(usage); + assert.equal(decoded.text, "ACK"); + assert.equal(decoded.usage, undefined, `expected no usage for ${JSON.stringify(usage)}`); + } +}); + +test("a malformed num_turns or total_cost_usd degrades to zero without discarding the token counts", () => { + const decoded = turn(fullUsage, { num_turns: "3", total_cost_usd: "0.0035" }); + assert.equal(decoded.usage?.total, 8_746 + 29 + 5_760 + 12); + assert.equal(decoded.usage?.calls, 0); + assert.equal(decoded.usage?.notionalUsd, 0); + assert.equal(turn(fullUsage, { total_cost_usd: -1 }).usage?.notionalUsd, 0); +}); + +test("an error stream carrying plausible usage still throws rather than publishing a metered turn", () => { + assert.throws(() => decodeGrokHeadlessTurn(stream( + assistant("ACK"), + { type: "error", message: "engine died", usage: fullUsage, total_cost_usd: 9.99 } + )), /no publishable terminal response/u); +}); + +test("no engine-controlled string from the usage block reaches the decoded record", () => { + const canary = "usage-canary-must-not-persist"; + const decoded = turn({ + ...fullUsage, model: canary, note: canary, + server_tool_use: { web_search_requests: 0, label: canary } + }, { modelUsage: { [canary]: { inputTokens: 1, costUSD: 1 } } }); + assert.equal(JSON.stringify(decoded.usage).includes(canary), false); + assert.equal(decoded.usage?.total, 8_746 + 29 + 5_760 + 12); +}); + +test("the text-only decoder still returns exactly the published reply", () => { + assert.equal(decodeGrokHeadlessResult(stream(assistant("ACK"), result(fullUsage))), "ACK"); +}); diff --git a/src/runtime/grokEngineBroker.ts b/src/runtime/grokEngineBroker.ts index 65d251f..6ae6eaf 100644 --- a/src/runtime/grokEngineBroker.ts +++ b/src/runtime/grokEngineBroker.ts @@ -1,5 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; -import { decodeGrokHeadlessResult } from "../pi/grokHeadlessResult.js"; +import { decodeGrokHeadlessTurn } from "../pi/grokHeadlessResult.js"; +import { recordTurnUsage, TURN_USAGE_LEDGER, type TurnUsageEntry } from "./turnUsageLedger.js"; import { DurableGrokBrokerCredentialAuthority } from "./grokBrokerCredentialAuthority.js"; import { NativeBrokerTurnFailure, runNativeBrokerTurn, type NativeBrokerDiagnostic } from "./engineBrokerNativeClient.js"; import { EngineBrokerTurnRegistry } from "./engineBrokerTurnRegistry.js"; @@ -11,7 +12,28 @@ import { GrokWorkerAttestationFailure,prepareGrokWorkerAttestation,verifyGrokWor export type GrokEngineBrokerRegistration = Readonly<{ agentId:string;slot:number;workerUid:number;workspace:string;profilePath:string;eventsPath:string;profileSha256:string }>; export type GrokEngineBroker = Awaited>; export class EngineBrokerTurnFailure extends Error{constructor(readonly code:"auth_stale"|"cancelled"|"engine_failed",readonly diagnostic?:NativeBrokerDiagnostic){super("engine broker turn failed");}} -export async function startGrokEngineBroker(options: Readonly<{ grokCommand: string; nativeClient: string; credentialHome: string; turnStore: string; registrations: readonly GrokEngineBrokerRegistration[] }>) { + +/** + * Seal a completed turn, then meter it. + * + * Order is load-bearing. `turns.finish` publishes the durable *completed* + * record; only after that does the advisory usage line get appended. A replayed + * turn returns before the enclosing `try` block and never reaches here, so a + * crash-recovered turn cannot double-count. Usage is deliberately kept out of + * the `completed` frame itself: that record is re-validated by the strict wire + * parser on the next `begin()`, whose exact field set would reject an extra key + * and break crash-recovery replay permanently. + * + * `recordTurnUsage` never rejects, so an append failure cannot escape into the + * caller's `catch` and rewrite this already-completed turn as failed. + */ +export async function finishBrokerTurnWithUsage(turns: EngineBrokerTurnRegistry, request: Parameters[0], completed: Parameters[1], usageLedgerPath: string, usage: TurnUsageEntry["usage"] | undefined, agentId: string, wakeId: string): Promise { + await turns.finish(request, completed); + if (usage === undefined) return; + await recordTurnUsage(usageLedgerPath, { agent: agentId, wake: wakeId, engine: "grok", usage }); +} +export async function startGrokEngineBroker(options: Readonly<{ grokCommand: string; nativeClient: string; credentialHome: string; turnStore: string; registrations: readonly GrokEngineBrokerRegistration[]; usageLedgerPath?: string }>) { + const usageLedgerPath = options.usageLedgerPath ?? TURN_USAGE_LEDGER.filePath; const registrations = new Map(options.registrations.map((entry) => [entry.agentId, entry])); if (registrations.size !== options.registrations.length) throw new Error("engine broker registration conflict"); const lease=await acquireGrokBrokerRealmLease(options.credentialHome);const authority = new DurableGrokBrokerCredentialAuthority(options.grokCommand, options.credentialHome);try{await authority.initialize();}catch(error){await lease.close();throw error;} let proxy:Awaited>;try{proxy=await startGrokBrokerProxy(authority);}catch(error){await lease.close();throw error;}let mcp:Awaited>|undefined;try{mcp=await startEngineBrokerMcpFacade();for(const registration of registrations.values())await prepareGrokWorkerAttestation({...registration,brokerGid:2100});}catch(error){if(mcp)await mcp.close().catch(()=>undefined);await proxy.close();await lease.close();throw error;}if(!mcp)throw new Error("engine broker unavailable");const turns = new EngineBrokerTurnRegistry(options.turnStore); const active = new Map; resolve: () => void }>(); let closed = false; return { @@ -21,7 +43,7 @@ export async function startGrokEngineBroker(options: Readonly<{ grokCommand: str const begun = await turns.begin(request); if (begun !== "start") { if (begun.replay.kind === "completed") return {text:begun.replay.text,workerPid:begun.replay.workerPid,workerUid:begun.replay.workerUid,workerStartTime:begun.replay.workerStartTime};const code=begun.replay.code==="auth_stale"||begun.replay.code==="cancelled"?begun.replay.code:"engine_failed";throw new EngineBrokerTurnFailure(code,begun.replay.diagnostic as NativeBrokerDiagnostic|undefined); } const isolation=await prepareGrokWorkerAttestation({...registration,brokerGid:2100});proxy.registerIsolationGuard(turnId,()=>verifyGrokWorkerAttestation({...registration,brokerGid:2100},isolation));const providerCapability = proxy.capabilities.issue(agentId, turnId);const mcpCapability=mcp.register(agentId,turnId,mcpEndpoint); const controller = new AbortController();const onAbort=()=>controller.abort();signal?.addEventListener("abort",onAbort,{once:true});if(signal?.aborted)controller.abort(); let resolve!:()=>void;const done=new Promise((value)=>{resolve=value;});active.set(turnId,{controller,done,resolve}); let nativeDiagnostic:NativeBrokerDiagnostic|undefined,attested=false; - try { const result = await runNativeBrokerTurn(options.nativeClient, { slot: registration.slot, requestId: request.requestId, turnId, agentId, wakeId, prompt, providerCapability,mcpCapability }, controller.signal);nativeDiagnostic=result.diagnostic;if(result.workerUid!==registration.workerUid)throw new Error();await verifyGrokWorkerAttestation({...registration,brokerGid:2100},isolation);attested=true; const text = decodeGrokHeadlessResult(result.text);const completed={ version: request.version, kind: "completed", requestId: request.requestId, turnId, text, workerPid: result.workerPid, workerUid: result.workerUid, workerStartTime: result.startTicks.toString() } as const; await turns.finish(request,completed); return {text,workerPid:result.workerPid,workerUid:result.workerUid,workerStartTime:result.startTicks.toString()}; } + try { const result = await runNativeBrokerTurn(options.nativeClient, { slot: registration.slot, requestId: request.requestId, turnId, agentId, wakeId, prompt, providerCapability,mcpCapability }, controller.signal);nativeDiagnostic=result.diagnostic;if(result.workerUid!==registration.workerUid)throw new Error();await verifyGrokWorkerAttestation({...registration,brokerGid:2100},isolation);attested=true; const decoded = decodeGrokHeadlessTurn(result.text);const text = decoded.text;const completed={ version: request.version, kind: "completed", requestId: request.requestId, turnId, text, workerPid: result.workerPid, workerUid: result.workerUid, workerStartTime: result.startTicks.toString() } as const; await finishBrokerTurnWithUsage(turns,request,completed,usageLedgerPath,decoded.usage,agentId,wakeId); return {text,workerPid:result.workerPid,workerUid:result.workerUid,workerStartTime:result.startTicks.toString()}; } catch(error) { const code=authority.isStale()?"auth_stale":controller.signal.aborted?"cancelled":"engine_failed";const diagnostic=error instanceof NativeBrokerTurnFailure?error.diagnostic:nativeDiagnostic&&!attested?{...nativeDiagnostic,status:"worker_failed" as const,stage:"attestation" as const,failureClass:error instanceof GrokWorkerAttestationFailure?error.failureClass:"profile_invalid" as const,profileApplied:false}:undefined;await turns.finish(request, { version: request.version, kind: "failed", requestId: request.requestId, turnId, code,...(diagnostic?{diagnostic}:{}) }); throw new EngineBrokerTurnFailure(code,diagnostic); } finally { signal?.removeEventListener("abort",onAbort);active.get(turnId)?.resolve(); active.delete(turnId); proxy.revokeIsolationGuard(turnId);proxy.capabilities.revoke(turnId);mcp.revoke(turnId); } }, diff --git a/src/runtime/grokEngineBrokerUsage.test.ts b/src/runtime/grokEngineBrokerUsage.test.ts new file mode 100644 index 0000000..779fa18 --- /dev/null +++ b/src/runtime/grokEngineBrokerUsage.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { EngineBrokerTurnRegistry } from "./engineBrokerTurnRegistry.js"; +import { finishBrokerTurnWithUsage } from "./grokEngineBroker.js"; +import { TURN_USAGE_LEDGER_VERSION } from "./turnUsageLedger.js"; + +const usage = { input: 8_746, output: 29, cacheRead: 5_760, cacheWrite: 12, total: 14_547, calls: 1, notionalUsd: 0.0035, complete: true }; + +const startRequest = (agentId: string, wakeId: string) => ({ + version: "noopolis.daimon.engine-broker.v1", kind: "start_turn", requestId: randomUUID(), + turnId: createHash("sha256").update(`${agentId}\0${wakeId}`).digest("hex"), + agentId, wakeId, prompt: "prompt", mcpEndpoint: "http://127.0.0.1:43124/mcp" +} as const); + +const completedFor = (request: ReturnType) => ({ + version: request.version, kind: "completed", requestId: request.requestId, turnId: request.turnId, + text: "ACK", workerPid: 4_242, workerUid: 2_200, workerStartTime: "99" +} as const); + +/** + * Reproduces the broker's turn control flow around the registry: replayed turns + * return before any work, and a fresh turn seals the record and then meters it. + * Everything but the engine call itself is the real production code. + */ +const runTurn = async (turns: EngineBrokerTurnRegistry, ledger: string, agentId: string, wakeId: string): Promise<"start" | "replay"> => { + const request = startRequest(agentId, wakeId); + const begun = await turns.begin(request); + if (begun !== "start") return "replay"; + await finishBrokerTurnWithUsage(turns, request, completedFor(request), ledger, usage, agentId, wakeId); + return "start"; +}; + +const withStore = async (body: (turnStore: string, ledger: string, root: string) => Promise): Promise => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-broker-usage-")); + try { await body(path.join(root, "turns"), path.join(root, "usage.jsonl"), root); } finally { await rm(root, { recursive: true, force: true }); } +}; + +const ledgerLines = async (file: string): Promise[]> => { + const text = await readFile(file, "utf8").catch(() => ""); + return text.split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line) as Record); +}; + +test("a completed broker turn writes exactly one metered line, and a replayed turn writes no second one", async () => { + await withStore(async (turnStore, ledger) => { + const turns = new EngineBrokerTurnRegistry(turnStore); + assert.equal(await runTurn(turns, ledger, "cogsworth", "wake-1"), "start"); + assert.deepEqual(await runTurn(turns, ledger, "cogsworth", "wake-1"), "replay"); + + // Mutation guard: removing the replay suppression makes the same wake + // append a second line and double-count the subscription. + const written = await ledgerLines(ledger); + assert.equal(written.length, 1); + assert.deepEqual(written[0], { + v: TURN_USAGE_LEDGER_VERSION, agent: "cogsworth", wake: "wake-1", engine: "grok", + at: written[0]?.at, input: 8_746, output: 29, cache_read: 5_760, cache_write: 12, + total: 14_547, calls: 1, notional_usd: 0.0035, complete: true + }); + + assert.equal(await runTurn(turns, ledger, "cogsworth", "wake-2"), "start"); + assert.equal((await ledgerLines(ledger)).length, 2); + }); +}); + +test("crash recovery replays the same completed turn without metering it again", async () => { + await withStore(async (turnStore, ledger) => { + assert.equal(await runTurn(new EngineBrokerTurnRegistry(turnStore), ledger, "foreman", "wake-9"), "start"); + // A fresh boot id is what the broker gets after a crash. + assert.equal(await runTurn(new EngineBrokerTurnRegistry(turnStore), ledger, "foreman", "wake-9"), "replay"); + assert.equal((await ledgerLines(ledger)).length, 1); + }); +}); + +test("an unwritable ledger leaves the turn recorded as completed, not failed", async () => { + // Mutation guard: deleting the advisory try/catch in recordTurnUsage makes + // finishBrokerTurnWithUsage reject. In the broker that rejection lands in the + // catch that calls finish(..., failed), which renames over this already + // completed record — turning a published turn into a failed one. + await withStore(async (turnStore, _ledger, root) => { + const turns = new EngineBrokerTurnRegistry(turnStore); + const unwritable = path.join(root, "not-provisioned", "usage.jsonl"); + const request = startRequest("brass", "wake-3"); + assert.equal(await turns.begin(request), "start"); + await assert.doesNotReject(finishBrokerTurnWithUsage(turns, request, completedFor(request), unwritable, usage, "brass", "wake-3")); + + const replayed = await new EngineBrokerTurnRegistry(turnStore).begin(request); + assert.notEqual(replayed, "start"); + assert.equal((replayed as { replay: { kind: string } }).replay.kind, "completed"); + }); +}); + +test("a turn whose usage could not be decoded is sealed but writes no line", async () => { + await withStore(async (turnStore, ledger) => { + const turns = new EngineBrokerTurnRegistry(turnStore); + const request = startRequest("brass", "wake-4"); + assert.equal(await turns.begin(request), "start"); + await finishBrokerTurnWithUsage(turns, request, completedFor(request), ledger, undefined, "brass", "wake-4"); + assert.deepEqual(await ledgerLines(ledger), []); + assert.notEqual(await new EngineBrokerTurnRegistry(turnStore).begin(request), "start"); + }); +}); + +test("usage is never written into the completed frame the strict wire parser re-validates", async () => { + await withStore(async (turnStore, ledger) => { + const turns = new EngineBrokerTurnRegistry(turnStore); + assert.equal(await runTurn(turns, ledger, "cogsworth", "wake-5"), "start"); + // The durable record is re-parsed on the next begin(); an extra field there + // makes it throw permanently and breaks crash-recovery replay for good. + const replayed = await new EngineBrokerTurnRegistry(turnStore).begin(startRequest("cogsworth", "wake-5")); + const response = (replayed as { replay: Record }).replay; + assert.deepEqual(Object.keys(response).sort(), ["kind", "requestId", "text", "turnId", "version", "workerPid", "workerStartTime", "workerUid"]); + }); +}); + +test("the broker meters only on the success path, through the single sealing helper", async () => { + const source = await readFile(path.join(path.dirname(fileURLToPath(import.meta.url)), "grokEngineBroker.ts"), "utf8"); + const body = source.slice(source.indexOf("async turn(")); + assert.equal(body.includes("recordTurnUsage("), false, "the broker must meter only through finishBrokerTurnWithUsage"); + assert.equal((body.match(/finishBrokerTurnWithUsage\(/gu) ?? []).length, 1, "exactly one metering call, in the success branch"); + assert.equal(body.includes("turns.finish(request,completed)"), false, "the success branch must seal through the metering helper"); + assert.ok(body.indexOf("finishBrokerTurnWithUsage(") < body.indexOf("catch(error)"), "metering belongs to the success branch"); +}); diff --git a/src/runtime/turnUsageLedger.test.ts b/src/runtime/turnUsageLedger.test.ts new file mode 100644 index 0000000..30d92e7 --- /dev/null +++ b/src/runtime/turnUsageLedger.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, truncate, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + recordTurnUsage, + resolveTurnUsageLedgerPath, + TURN_USAGE_ENGINES, + TURN_USAGE_LEDGER_PATH_ENV, + renderTurnUsageLine, + TURN_USAGE_LEDGER, + TURN_USAGE_LEDGER_VERSION, + TURN_USAGE_MAX_IDENTIFIER_CHARS, + TURN_USAGE_ROTATE_BYTES, + type TurnUsageEntry +} from "./turnUsageLedger.js"; + +const measurement = { input: 8_746, output: 29, cacheRead: 5_760, cacheWrite: 12, total: 14_547, calls: 1, notionalUsd: 0.0035, complete: true }; +const entry = (overrides: Partial = {}): TurnUsageEntry => + ({ agent: "cogsworth", wake: "wake-1", engine: "grok", usage: measurement, at: "2026-08-29T01:12:04.000Z", ...overrides }); + +const withLedger = async (body: (file: string, directory: string) => Promise): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), "daimon-turn-usage-")); + try { await body(path.join(directory, "usage.jsonl"), directory); } finally { await rm(directory, { recursive: true, force: true }); } +}; + +const lines = async (file: string): Promise => + (await readFile(file, "utf8")).split("\n").filter((line) => line.length > 0); + +test("a record is one complete newline-terminated line carrying only numbers plus agent, wake, and engine", async () => { + const line = renderTurnUsageLine(entry()); + assert.equal(line.endsWith("\n"), true); + assert.equal(line.slice(0, -1).includes("\n"), false); + assert.deepEqual(JSON.parse(line), { + v: TURN_USAGE_LEDGER_VERSION, agent: "cogsworth", wake: "wake-1", engine: "grok", + at: "2026-08-29T01:12:04.000Z", + input: 8_746, output: 29, cache_read: 5_760, cache_write: 12, + total: 14_547, calls: 1, notional_usd: 0.0035, complete: true + }); + assert.equal("org" in JSON.parse(line), false, "identity comes from which container was queried, not from the record"); +}); + +test("caller-supplied agent and wake text is truncated and cannot inject a second line", async () => { + const parsed = JSON.parse(renderTurnUsageLine(entry({ agent: "a".repeat(5_000), wake: `w\n${JSON.stringify({ v: "forged" })}\n${"b".repeat(5_000)}` }))); + assert.equal([...parsed.agent].length, TURN_USAGE_MAX_IDENTIFIER_CHARS); + assert.equal([...parsed.wake].length, TURN_USAGE_MAX_IDENTIFIER_CHARS); + const line = renderTurnUsageLine(entry({ wake: "w\n{\"v\":\"forged\"}" })); + assert.equal(line.split("\n").filter((part) => part.length > 0).length, 1); +}); + +test("appends accumulate one line per turn and survive concurrent writers", async () => { + await withLedger(async (file) => { + await Promise.all(Array.from({ length: 64 }, (_, index) => + recordTurnUsage(file, entry({ agent: `agent-${index % 8}`, wake: `wake-${index}` })))); + const written = await lines(file); + assert.equal(written.length, 64); + for (const line of written) assert.equal(JSON.parse(line).v, TURN_USAGE_LEDGER_VERSION); + assert.equal(new Set(written.map((line) => JSON.parse(line).wake)).size, 64); + assert.equal(((await stat(file)).mode & 0o777) & 0o007, 0, "the ledger must not be world-readable"); + }); +}); + +test("a failed append never throws, so a completed turn is never rewritten as failed", async () => { + // Mutation guard: deleting the try/catch in recordTurnUsage turns every one + // of these into a rejection, which in the broker escapes into the catch that + // calls finish(..., failed) over an already-completed turn record. + await withLedger(async (file, directory) => { + const unwritable = path.join(directory, "nested", "missing", "usage.jsonl"); + await assert.doesNotReject(recordTurnUsage(unwritable, entry())); + + const asDirectory = path.join(directory, "as-directory.jsonl"); + await mkdir(asDirectory); + await assert.doesNotReject(recordTurnUsage(asDirectory, entry())); + + await writeFile(file, ""); + await chmod(file, 0o400); + await assert.doesNotReject(recordTurnUsage(file, entry())); + await chmod(file, 0o600); + + await assert.doesNotReject(recordTurnUsage(file, entry({ usage: { ...measurement, notionalUsd: Number.NaN } }))); + }); +}); + +test("the ledger rotates once at the size bound and keeps exactly one generation", async () => { + await withLedger(async (file) => { + await writeFile(file, "old\n"); + await truncate(file, TURN_USAGE_ROTATE_BYTES); + await recordTurnUsage(file, entry({ wake: "after-rotation" })); + + assert.deepEqual((await lines(file)).map((line) => JSON.parse(line).wake), ["after-rotation"]); + assert.equal((await stat(`${file}.1`)).size, TURN_USAGE_ROTATE_BYTES); + + await recordTurnUsage(file, entry({ wake: "still-current" })); + assert.equal((await lines(file)).length, 2, "a below-bound ledger must not rotate again"); + }); +}); + +test("the shared ledger location is the root-provisioned usage volume, not the broker realm", () => { + assert.equal(TURN_USAGE_LEDGER.directoryPath, "/var/lib/spawnfile/daimon/usage"); + assert.equal(TURN_USAGE_LEDGER.filePath, `${TURN_USAGE_LEDGER.directoryPath}/usage.jsonl`); + assert.equal(TURN_USAGE_LEDGER.rotatedFilePath, `${TURN_USAGE_LEDGER.filePath}.1`); + assert.equal(TURN_USAGE_LEDGER.directoryMode, 0o750); + assert.equal(TURN_USAGE_LEDGER.fileMode, 0o640); +}); + +test("an AGY turn renders the same record shape, labelled agy", () => { + const parsed = JSON.parse(renderTurnUsageLine(entry({ + engine: "agy", + usage: { input: 44_937, output: 444, cacheRead: 0, cacheWrite: 0, total: 45_381, calls: 1, notionalUsd: 0, complete: true } + }))); + assert.deepEqual(parsed, { + v: TURN_USAGE_LEDGER_VERSION, agent: "cogsworth", wake: "wake-1", engine: "agy", + at: "2026-08-29T01:12:04.000Z", + input: 44_937, output: 444, cache_read: 0, cache_write: 0, + total: 45_381, calls: 1, notional_usd: 0, complete: true + }); + assert.deepEqual([...TURN_USAGE_ENGINES], ["agy", "grok"], "codex is uninstrumented and must not claim zero usage"); +}); + +test("the ledger path override is honoured only when it is absolute", () => { + assert.equal(resolveTurnUsageLedgerPath({}), TURN_USAGE_LEDGER.filePath); + assert.equal(resolveTurnUsageLedgerPath({ [TURN_USAGE_LEDGER_PATH_ENV]: " " }), TURN_USAGE_LEDGER.filePath); + assert.equal(resolveTurnUsageLedgerPath({ [TURN_USAGE_LEDGER_PATH_ENV]: "relative/usage.jsonl" }), TURN_USAGE_LEDGER.filePath); + assert.equal(resolveTurnUsageLedgerPath({ [TURN_USAGE_LEDGER_PATH_ENV]: " /tmp/usage.jsonl " }), "/tmp/usage.jsonl"); +}); diff --git a/src/runtime/turnUsageLedger.ts b/src/runtime/turnUsageLedger.ts new file mode 100644 index 0000000..4aaf6ef --- /dev/null +++ b/src/runtime/turnUsageLedger.ts @@ -0,0 +1,149 @@ +import { constants } from "node:fs"; +import { open, rename, stat } from "node:fs/promises"; + +/** + * Append-only per-turn token accounting for one container. + * + * The record carries no organization: there is one ledger per container and one + * organization per container, so identity comes from which container was + * queried. Nothing engine-controlled and non-numeric is ever persisted. + */ +export const TURN_USAGE_LEDGER_VERSION = "noopolis.daimon.turn-usage.v1" as const; + +/** + * Where the broker writes, and what Spawnfile provisions. + * + * Deliberately *not* folded into `RUNTIME_CONTRACT_MANIFEST`. That manifest's + * canonical bytes are digest-pinned by Spawnfile + * (`DAIMON_CONTRACT_MANIFEST_SHA256`) and attested against the runtime image + * selected at compile time, so any new key there makes every pinned image fail + * to attest and blocks all Daimon compiles until an image is rebuilt and + * re-pinned. Nothing about metering needs digest attestation: this is a path + * both sides agree on, mirrored in `spawnfile/src/runtime/daimon/`. Registering + * the schema in the manifest is bookkeeping that belongs with the next image + * rebuild. + */ +export const TURN_USAGE_LEDGER = { + version: TURN_USAGE_LEDGER_VERSION, + directoryPath: "/var/lib/spawnfile/daimon/usage", + filePath: "/var/lib/spawnfile/daimon/usage/usage.jsonl", + rotatedFilePath: "/var/lib/spawnfile/daimon/usage/usage.jsonl.1", + directoryMode: 0o750, + fileMode: 0o640 +} as const; + +/** + * `agent` and `wake` are caller-supplied external text (`wake` is `event.id` + * from the wake request, schema-bounded to 4096 codepoints). They are truncated + * so one record cannot grow past a size where a single `write(2)` stops being + * atomic. JSON escaping already prevents line injection. + */ +export const TURN_USAGE_MAX_IDENTIFIER_CHARS = 128; + +/** Rotate to a single `.1` sibling. ~2.5k lines/day observed, so this is months. */ +export const TURN_USAGE_ROTATE_BYTES = 64 * 1024 * 1024; + +export type TurnUsageMeasurement = Readonly<{ + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + calls: number; + notionalUsd: number; + complete: boolean; +}>; + +/** + * Every engine whose headless stream reports its own token accounting. + * + * Codex is deliberately absent: it is uninstrumented, and `spawnfile usage` + * renders a Codex agent as a dashed roster row rather than as zero usage. + */ +export const TURN_USAGE_ENGINES = ["agy", "grok"] as const; + +export type TurnUsageEntry = Readonly<{ + agent: string; + wake: string; + engine: typeof TURN_USAGE_ENGINES[number]; + usage: TurnUsageMeasurement; + at?: string; +}>; + +/** + * Where a metered turn is appended. + * + * Production is {@link TURN_USAGE_LEDGER}`.filePath`, which Spawnfile + * provisions as a non-run-scoped volume. `DAIMON_TURN_USAGE_LEDGER_PATH` + * redirects it to an absolute path elsewhere; it exists so the wiring between a + * live engine session and this ledger can be exercised end to end, and so an + * operator can relocate the ledger without a rebuild. A relative or empty value + * is ignored rather than honoured, because a ledger written to a + * process-relative path is a ledger nobody can find again. + */ +export const TURN_USAGE_LEDGER_PATH_ENV = "DAIMON_TURN_USAGE_LEDGER_PATH" as const; + +export const resolveTurnUsageLedgerPath = (environment: NodeJS.ProcessEnv = process.env): string => { + const override = environment[TURN_USAGE_LEDGER_PATH_ENV]?.trim(); + return override !== undefined && override.startsWith("/") ? override : TURN_USAGE_LEDGER.filePath; +}; + +const bounded = (value: string): string => [...value].slice(0, TURN_USAGE_MAX_IDENTIFIER_CHARS).join(""); + +/** One complete line, newline-terminated. `JSON.stringify` escapes any embedded newline. */ +export const renderTurnUsageLine = (entry: TurnUsageEntry): string => `${JSON.stringify({ + v: TURN_USAGE_LEDGER_VERSION, + agent: bounded(entry.agent), + wake: bounded(entry.wake), + engine: entry.engine, + at: entry.at ?? new Date().toISOString(), + input: entry.usage.input, + output: entry.usage.output, + cache_read: entry.usage.cacheRead, + cache_write: entry.usage.cacheWrite, + total: entry.usage.total, + calls: entry.usage.calls, + notional_usd: entry.usage.notionalUsd, + complete: entry.usage.complete +})}\n`; + +const rotate = async (file: string): Promise => { + let size: number; + try { size = (await stat(file)).size; } catch { return; } + if (size >= TURN_USAGE_ROTATE_BYTES) await rename(file, `${file}.1`); +}; + +/** + * One `write(2)` per complete line. Turns for different agents run concurrently + * inside the single broker process, and `O_APPEND` makes a single sub-page write + * atomic against them. The directory is root-provisioned, so this never creates + * it: a missing directory is a provisioning failure, not something to paper over. + */ +const appendLine = async (file: string, line: string): Promise => { + const bytes = Buffer.from(line, "utf8"); + const handle = await open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, TURN_USAGE_LEDGER.fileMode); + try { + const { bytesWritten } = await handle.write(bytes, 0, bytes.length); + if (bytesWritten !== bytes.length) throw new Error("turn usage ledger append was torn"); + } finally { await handle.close(); } +}; + +/** + * Advisory. Never rejects. + * + * The caller runs this inside the broker's turn `try` block, after the turn has + * already been recorded as *completed*. That block's `catch` calls + * `finish(..., failed)` unconditionally, and `finish` has no terminal-state + * guard — it renames over an existing record. An escaping append error would + * therefore rewrite an already-completed turn as failed. This guard is the only + * thing preventing that, so it is the one place where getting "advisory" wrong + * corrupts turn state instead of merely dropping a line. + */ +export const recordTurnUsage = async (file: string, entry: TurnUsageEntry): Promise => { + try { + await rotate(file); + await appendLine(file, renderTurnUsageLine(entry)); + } catch { + /* advisory: a failed append never fails a turn that published */ + } +}; From a9d4256ea8e3d51d573fb2405c322189f9428737 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:36:26 +0200 Subject: [PATCH 17/24] feat(agy): give AGY full tool cognition, MCP registration, and usage accounting --- src/contracts/runtimeContractManifest.ts | 2 +- src/mcp/AGENTS.md | 4 + src/mcp/toolServer.test.ts | 45 +++++++ src/mcp/toolServer.ts | 24 ++++ src/pi/agyHeadlessResult.test.ts | 133 ++++++++++++++++++ src/pi/agyHeadlessResult.ts | 136 +++++++++++++++++++ src/pi/cliEngineRun.test.ts | 3 +- src/pi/cliEngineRun.ts | 4 +- src/pi/cliEngineSpawn.ts | 55 +++++++- src/pi/cliMcpRegistration.test.ts | 57 ++++++++ src/pi/cliMcpRegistration.ts | 122 +++++++++++++++++ src/pi/cliSession.test.ts | 9 +- src/pi/cliSession.ts | 165 +++++++++++++---------- src/pi/cliSessionOutput.test.ts | 8 +- src/pi/cliSessionProcess.test.ts | 2 +- src/runtime/AGENTS.md | 18 ++- src/runtime/agyToolCognition.test.ts | 127 +++++++++++++++++ src/runtime/contractManifest.test.ts | 16 +++ 18 files changed, 840 insertions(+), 90 deletions(-) create mode 100644 src/pi/agyHeadlessResult.test.ts create mode 100644 src/pi/agyHeadlessResult.ts create mode 100644 src/pi/cliMcpRegistration.test.ts create mode 100644 src/pi/cliMcpRegistration.ts create mode 100644 src/runtime/agyToolCognition.test.ts diff --git a/src/contracts/runtimeContractManifest.ts b/src/contracts/runtimeContractManifest.ts index 2e5cf95..50454c1 100644 --- a/src/contracts/runtimeContractManifest.ts +++ b/src/contracts/runtimeContractManifest.ts @@ -58,7 +58,7 @@ const activityItem = { type: "object", additionalProperties: false, required: [" export const RUNTIME_CONTRACT_MANIFEST = { version: RUNTIME_CONTRACT_MANIFEST_VERSION, - consumedConfigFields: ["version", "host.bindHost", "host.port", "host.controlTokenEnv", "agents[].id", "agents[].name", "agents[].instructions", "agents[].workspacePath", "agents[].runtimeHomePath", "agents[].engine.kind", "agents[].schedule.kind", "agents[].schedule.interval_ms", "agents[].schedule.cron", "agents[].schedule.timezone", "agents[].schedule.prompt", "agents[].mcp", "agents[].moltnet"], + consumedConfigFields: ["version", "host.bindHost", "host.port", "host.controlTokenEnv", "agents[].id", "agents[].name", "agents[].instructions", "agents[].workspacePath", "agents[].runtimeHomePath", "agents[].engine.kind", "agents[].schedule.kind", "agents[].schedule.interval_ms", "agents[].schedule.cron", "agents[].schedule.timezone", "agents[].schedule.prompt", "agents[].mcp", "agents[].moltnet", "agents[].memory"], organizationRuntimeConfigSchema: ORGANIZATION_RUNTIME_CONFIG_SCHEMA, organizationRuntimeConfigV2Schema: ORGANIZATION_RUNTIME_CONFIG_V2_SCHEMA, wakeAcceptanceTypes: ["manual", "message", "schedule", "external"], diff --git a/src/mcp/AGENTS.md b/src/mcp/AGENTS.md index 667e2b6..967be76 100644 --- a/src/mcp/AGENTS.md +++ b/src/mcp/AGENTS.md @@ -11,3 +11,7 @@ This folder adapts Daimon's existing Pi `ToolDefinition` objects to MCP. - MCP exposes each supplied Pi TypeBox/JSON-Schema `parameters` object verbatim and validates calls against that same object with a JSON-Schema validator. There is no schema conversion layer that can silently discard constraints. +- One non-standard client request is answered: AGY's handshake sends + `server/discover`, which is not in the MCP spec. It is answered with `{}`, + the way the probe that proved AGY's headless tool calling answered it. Every + other unknown method still gets `MethodNotFound`; do not widen that. diff --git a/src/mcp/toolServer.test.ts b/src/mcp/toolServer.test.ts index ca66b08..cadc6b1 100644 --- a/src/mcp/toolServer.test.ts +++ b/src/mcp/toolServer.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { ResultSchema } from "@modelcontextprotocol/sdk/types.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { Type } from "@earendil-works/pi-ai"; import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent"; @@ -11,11 +12,14 @@ import { createPiMemoryTools } from "../pi/memoryTools.js"; import { createPiWorldTools } from "../pi/worldTools.js"; import type { PiWorldToolContextRef } from "../pi/worldNudge.js"; import { + AGY_SERVER_DISCOVER_METHOD, createPiToolMcpServer, McpToolTurnLimitError, McpWakeDeadlineError } from "./toolServer.js"; +const PassthroughResultSchema = ResultSchema; + const call = async (server: ReturnType, name: string, args: Record) => { const client = new Client({ name: "daimon-test-client", version: "0.1.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -277,3 +281,44 @@ test("mounted tool execution receives no Pi ExtensionContext", async () => { assert.notEqual(result.isError, true, JSON.stringify(result)); assert.equal(received, undefined); }); + +const rawRequest = async ( + server: ReturnType, + method: string +): Promise<{ error?: { code: number; message: string }; result?: unknown }> => { + const client = new Client({ name: "daimon-raw-client", version: "0.1.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + // `z.any()`-shaped passthrough: the point is what the *server* answers to a + // method the SDK has no schema for, not what the client's parser prefers. + const result = await client.request({ method, params: {} }, PassthroughResultSchema); + return { result }; + } catch (error) { + const candidate = error as { code?: number; message?: string }; + return { error: { code: candidate.code ?? 0, message: candidate.message ?? String(error) } }; + } finally { + await client.close(); + await server.close(); + } +}; + +test("the AGY handshake's non-standard server/discover is answered, not refused", async () => { + // Captured live from `agy --print … --output-format stream-json`: AGY sends + // `initialize`, `notifications/initialized`, `server/discover`, `tools/list`, + // `tools/call`. `server/discover` is not in the MCP spec, and the probe that + // proved AGY's tool calling work answered it with `{}`. A MethodNotFound here + // is the one difference between this server and that probe. + const answered = await rawRequest(createPiToolMcpServer([], {}), AGY_SERVER_DISCOVER_METHOD); + assert.equal(answered.error, undefined, JSON.stringify(answered.error)); + assert.deepEqual(answered.result, {}); +}); + +test("every other unknown method still gets MethodNotFound", async () => { + for (const method of ["resources/list", "prompts/list", "completion/complete", "server/anything-else"]) { + const refused = await rawRequest(createPiToolMcpServer([], {}), method); + assert.equal(refused.result, undefined, method); + assert.equal(refused.error?.code, -32_601, `${method}: ${JSON.stringify(refused.error)}`); + } +}); diff --git a/src/mcp/toolServer.ts b/src/mcp/toolServer.ts index 677735c..a2bfcbc 100644 --- a/src/mcp/toolServer.ts +++ b/src/mcp/toolServer.ts @@ -27,6 +27,24 @@ export class McpWakeDeadlineError extends Error { } } +/** + * A non-standard request AGY issues during its MCP handshake. + * + * Captured live from `agy --print … --output-format stream-json`, the client + * sends `initialize`, `notifications/initialized`, **`server/discover`**, + * `tools/list`, `tools/call`. `server/discover` is not in the MCP + * specification, and the throwaway probe that proved AGY's headless tool + * calling answered it with `{}`. Codex and Grok never send it. + * + * This server answers it the same way that working probe did, rather than the + * SDK default of `MethodNotFound`, because a refusal here is the one observable + * difference between this server and the one AGY is known to work against, and + * the cost of being wrong is that every AGY agent silently loses every tool. + * The allowance is exactly this one method: any other unknown method still gets + * `MethodNotFound`, so a genuine protocol mistake is never hidden. + */ +export const AGY_SERVER_DISCOVER_METHOD = "server/discover" as const; + export interface PiToolMcpServerOptions { readonly maxToolTurns?: number; readonly wakeDeadline?: number; @@ -73,6 +91,12 @@ export const createPiToolMcpServer = ( ): Server => { validateOptions(options); const server = new Server({ name: "daimon-pi-tools", version: "0.1.2" }); + server.fallbackRequestHandler = async (request) => { + if (request.method !== AGY_SERVER_DISCOVER_METHOD) { + throw new McpError(ErrorCode.MethodNotFound, `Method not found: ${request.method}`); + } + return {}; + }; const validators = new Map(tools.map((tool): [string, ValidateFunction] => { const schema = jsonSchema(tool.parameters); return [tool.name, new Ajv2020({ strict: false }).compile(schema)]; diff --git a/src/pi/agyHeadlessResult.test.ts b/src/pi/agyHeadlessResult.test.ts new file mode 100644 index 0000000..2547bff --- /dev/null +++ b/src/pi/agyHeadlessResult.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { decodeAgyHeadlessResult, decodeAgyHeadlessTurn } from "./agyHeadlessResult.js"; + +const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); +const fixture = (name: string): string => readFileSync(path.join(fixtures, name), "utf8"); + +const PLAIN = "agy-stream-json-plain-result.jsonl"; +const TOOL = "agy-stream-json-tool-result.jsonl"; + +const resultFrame = (usage: unknown, extra: Record = {}) => ({ + event: "result", + result: { + conversation_id: "00000000-0000-4000-8000-00000000000a", + status: "SUCCESS", + response: "ACK", + duration_seconds: 1.5, + num_turns: 1, + ...(usage === undefined ? {} : { usage }), + ...extra + } +}); +const stream = (...events: readonly unknown[]): string => events.map((event) => JSON.stringify(event)).join("\n"); +const turn = (usage: unknown, extra: Record = {}) => + decodeAgyHeadlessTurn(stream({ event: "init", init: {} }, resultFrame(usage, extra))); + +test("the live plain fixture decodes to the measured turn", () => { + const decoded = decodeAgyHeadlessTurn(fixture(PLAIN)); + assert.equal(decoded.text, "ok"); + assert.deepEqual(decoded.usage, { + input: 13_722, output: 74, cacheRead: 0, cacheWrite: 0, + // 13,722 + 0 + 74. `thinking_tokens: 73` is a SUBSET of `output_tokens` + // (the frame's own `total_tokens` is 13,796 = input + output), so adding it + // would double-count. + total: 13_796, + calls: 1, notionalUsd: 0, complete: true + }); +}); + +test("the live tool fixture reports the whole turn, not just its last step", () => { + const decoded = decodeAgyHeadlessTurn(fixture(TOOL)); + assert.equal(decoded.text, "GLYPH-TANGERINE-4471"); + // The terminal frame's usage is the sum over the turn's model steps + // (14,579 + 15,079 + 15,279 input), not the final step's 15,279. Reading a + // `step_update` frame instead would under-report a tool-using wake threefold. + assert.deepEqual(decoded.usage, { + input: 44_937, output: 444, cacheRead: 0, cacheWrite: 0, + total: 45_381, calls: 1, notionalUsd: 0, complete: true + }); +}); + +test("one tool call costs AGY roughly three times a tool-free turn", () => { + const plain = decodeAgyHeadlessTurn(fixture(PLAIN)).usage!; + const tool = decodeAgyHeadlessTurn(fixture(TOOL)).usage!; + assert.ok(tool.total > plain.total * 3, `tool turn ${tool.total} vs plain ${plain.total}`); +}); + +test("an all-zero usage block is unknown, not free", () => { + assert.equal(turn({ input_tokens: 0, output_tokens: 0, cache_read_tokens: 0 }).usage?.complete, false); + assert.equal( + turn({ input_tokens: 0, output_tokens: 74, cache_read_tokens: 0 }).usage?.complete, + true, + "a partially zero-filled turn is byte-indistinguishable and is not detected" + ); +}); + +test("a usage block whose own total contradicts the buckets is reported as a lower bound", () => { + const decoded = turn({ input_tokens: 10, output_tokens: 5, cache_read_tokens: 2, total_tokens: 99 }); + assert.equal(decoded.usage?.total, 99, "never report less than either side claims"); + assert.equal(decoded.usage?.complete, false, "an unreconciled total is not a verified count"); + const understated = turn({ input_tokens: 100, output_tokens: 5, cache_read_tokens: 2, total_tokens: 3 }); + assert.equal(understated.usage?.total, 107, "a total below the buckets never wins"); + assert.equal(understated.usage?.complete, false); + const agreeing = turn({ input_tokens: 10, output_tokens: 5, cache_read_tokens: 2, total_tokens: 17 }); + assert.equal(agreeing.usage?.total, 17); + assert.equal(agreeing.usage?.complete, true); +}); + +test("cache_read_input_tokens is Grok's field name and is not AGY's", () => { + // AGY emits `cache_read_tokens`. Accepting Grok's name here would silently + // drop every cached prompt token AGY ever reports. + assert.equal(turn({ input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 }).usage, undefined); + assert.equal(turn({ input_tokens: 10, output_tokens: 5, cache_read_tokens: 2 }).usage?.cacheRead, 2); +}); + +test("a renamed, negative, fractional, or stringified bucket rejects the whole block", () => { + for (const usage of [ + { output_tokens: 5, cache_read_tokens: 0 }, + { input_tokens: -1, output_tokens: 5, cache_read_tokens: 0 }, + { input_tokens: 1.5, output_tokens: 5, cache_read_tokens: 0 }, + { input_tokens: "10", output_tokens: 5, cache_read_tokens: 0 }, + { input_tokens: 10, output_tokens: 5, cache_read_tokens: 0, total_tokens: -1 } + ]) { + assert.equal(turn(usage).usage, undefined, JSON.stringify(usage)); + } + assert.equal(turn(undefined).usage, undefined, "an absent usage block never fails a turn that published"); +}); + +test("only a terminal SUCCESS result frame publishes", () => { + assert.throws(() => decodeAgyHeadlessResult(""), /no publishable terminal response/u); + assert.throws(() => decodeAgyHeadlessResult("{not json}"), /no publishable terminal response/u); + assert.throws( + () => decodeAgyHeadlessResult(stream(resultFrame(undefined, { status: "ERROR" }))), + /no publishable terminal response/u + ); + assert.throws( + () => decodeAgyHeadlessResult(stream(resultFrame(undefined, { response: " " }))), + /no publishable terminal response/u + ); + assert.throws( + () => decodeAgyHeadlessResult(stream({ event: "init", init: {} })), + /no publishable terminal response/u + ); + assert.throws( + () => decodeAgyHeadlessResult(stream(resultFrame(undefined), { event: "step_update", step_update: {} })), + /no publishable terminal response/u + ); +}); + +test("unknown event kinds are ignored so a new AGY frame cannot break a published turn", () => { + assert.equal(decodeAgyHeadlessResult(stream({ event: "telemetry", whatever: 1 }, resultFrame(undefined))), "ACK"); +}); + +test("the captured fixtures carry no capturing machine's environment", () => { + for (const name of [PLAIN, TOOL]) { + const text = fixture(name); + assert.equal(/apresmoi|claude-501|scratchpad|\/Users\//u.test(text), false, name); + } +}); diff --git a/src/pi/agyHeadlessResult.ts b/src/pi/agyHeadlessResult.ts new file mode 100644 index 0000000..26593df --- /dev/null +++ b/src/pi/agyHeadlessResult.ts @@ -0,0 +1,136 @@ +type JsonRecord = Readonly>; + +const isRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value); + +const invalidResult = (detail: string): Error => + new Error(`AGY CLI returned no publishable terminal response: ${detail}`); + +/** + * Token accounting for one AGY turn, as the engine itself reported it. + * + * Same shape and the same discipline as {@link import("./grokHeadlessResult.js").GrokTurnUsage}, + * but AGY's envelope is *not* Grok's. Field provenance is the terminal frame of + * `agy --print … --output-format stream-json`: + * + * {"event":"result","result":{ "status":"SUCCESS", "response":"…", + * "num_turns":1, + * "usage":{"input_tokens":…, "output_tokens":…, + * "thinking_tokens":…, "cache_read_tokens":…, "total_tokens":…}}} + * + * Differences from Grok that this decoder exists to get right: + * + * - the prompt-side cache bucket is `cache_read_tokens`, **not** + * `cache_read_input_tokens`; + * - there is no `cache_creation_input_tokens` bucket at all, so `cacheWrite` is + * structurally absent and recorded as `0` — absent, not measured-as-zero; + * - there is no `total_cost_usd`, so `notionalUsd` is `0` for the same reason; + * - `thinking_tokens` has no Grok equivalent and is a **subset** of + * `output_tokens`, not a disjoint bucket. The captured plain turn is + * `input 13,722 + output 74 = 13,796 = total_tokens` with + * `thinking_tokens: 73`, so adding it would double-count reasoning tokens. + * It is therefore read only to be ignored; + * - AGY supplies its own `total_tokens`, which is cross-checked rather than + * trusted (see {@link decodeAgyTurnUsage}); + * - `status: "SUCCESS"` replaces Grok's `subtype`/`is_error` pair. + * + * The terminal frame's `usage` is the sum over the turn's model steps, not the + * last step's: the captured tool turn sums three `step_update` frames + * (14,579 + 15,079 + 15,279 input) into `input_tokens: 44,937`. Reading a + * `step_update` would under-report every tool-using wake. + */ +export type AgyTurnUsage = Readonly<{ + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + calls: number; + notionalUsd: number; + complete: boolean; +}>; + +export type AgyHeadlessTurn = Readonly<{ text: string; usage?: AgyTurnUsage }>; + +/** + * The three token buckets AGY emits on `result.usage`. All three must be + * present and be non-negative safe integers; a renamed, stringified, negative, + * or fractional field rejects the whole block rather than silently contributing + * a zero, because a zero bucket is byte-indistinguishable from a real one. + */ +const USAGE_TOKEN_FIELDS = ["input_tokens", "output_tokens", "cache_read_tokens"] as const; + +const tokenCount = (value: unknown): number | undefined => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; + +/** + * Extract per-turn usage from the terminal `result` frame. + * + * Advisory: a malformed or absent usage block yields `undefined` rather than + * failing a turn that published correctly, and never throws. + * + * `complete` follows exactly the honesty rule the Grok decoder states: AGY's + * envelope has no marker for incomplete or absent usage either, so an all-zero + * block reads as "unknown", not "free". It cannot catch a *partially* + * zero-filled turn, so every count this decoder produces is a lower bound. + * + * AGY additionally reports its own `total_tokens`. Neither side is trusted + * blindly: the derived sum `input + cacheRead + output` is compared against it, + * `total` is the larger of the two so a reconciliation failure can never + * under-report, and any disagreement clears `complete` so the record is + * rendered as an incomplete turn rather than a verified count. A `total_tokens` + * that is not a non-negative safe integer rejects the block outright, because + * an unreadable total is a total that cannot be reconciled. + */ +const decodeAgyTurnUsage = (result: JsonRecord): AgyTurnUsage | undefined => { + if (!isRecord(result.usage)) return undefined; + const usage = result.usage; + const counts = USAGE_TOKEN_FIELDS.map((field) => tokenCount(usage[field])); + if (counts.some((count) => count === undefined)) return undefined; + const [input, output, cacheRead] = counts as [number, number, number]; + const reported = usage.total_tokens === undefined ? undefined : tokenCount(usage.total_tokens); + if (usage.total_tokens !== undefined && reported === undefined) return undefined; + const derived = input + cacheRead + output; + return { + input, + output, + cacheRead, + cacheWrite: 0, + total: reported === undefined ? derived : Math.max(derived, reported), + calls: tokenCount(result.num_turns) ?? 0, + notionalUsd: 0, + complete: derived > 0 && (reported === undefined || reported === derived) + }; +}; + +/** + * Decode AGY's `stream-json` NDJSON without treating a progress frame as a + * reply, and extract the turn's own token accounting from the same frame. + * + * Frames before the terminal one are `{"event":"init"|"step_update", …}`. + * Unrecognized `event` values are skipped rather than rejected: AGY owns this + * stream and a frame kind added later must not fail a turn that published. + * The terminal `result` frame must be the last line, exactly as with Grok. + */ +export const decodeAgyHeadlessTurn = (output: string): AgyHeadlessTurn => { + const lines = output.split(/\r?\n/).filter((line) => line.length > 0); + if (lines.length === 0) throw invalidResult("empty stream"); + let result: JsonRecord | undefined; + for (const [index, line] of lines.entries()) { + let frame: unknown; + try { frame = JSON.parse(line); } catch { throw invalidResult("invalid JSON"); } + if (!isRecord(frame) || typeof frame.event !== "string") throw invalidResult("invalid event"); + if (frame.event !== "result") continue; + if (index !== lines.length - 1) throw invalidResult("non-terminal result"); + if (!isRecord(frame.result)) throw invalidResult("invalid result event"); + result = frame.result; + } + if (result === undefined) throw invalidResult("no result frame"); + if (result.status !== "SUCCESS") throw invalidResult("unsuccessful result"); + if (typeof result.response !== "string" || result.response.trim().length === 0) throw invalidResult("empty response"); + const usage = decodeAgyTurnUsage(result); + return usage === undefined ? { text: result.response.trim() } : { text: result.response.trim(), usage }; +}; + +/** Text-only view of {@link decodeAgyHeadlessTurn}, for callers that do not meter. */ +export const decodeAgyHeadlessResult = (output: string): string => decodeAgyHeadlessTurn(output).text; diff --git a/src/pi/cliEngineRun.test.ts b/src/pi/cliEngineRun.test.ts index 7ba0944..961a6b0 100644 --- a/src/pi/cliEngineRun.test.ts +++ b/src/pi/cliEngineRun.test.ts @@ -13,7 +13,8 @@ test("one-shot CLI helpers pass the assigned runtime home to every child", async const command = path.join(root, "agy"); await writeFile(command, [ "#!/usr/bin/env node", - "process.stdout.write(JSON.stringify({ home: process.env.HOME, config: process.env.XDG_CONFIG_HOME, data: process.env.XDG_DATA_HOME, state: process.env.XDG_STATE_HOME, cache: process.env.XDG_CACHE_HOME, tmp: process.env.TMPDIR }));" + "const response = JSON.stringify({ home: process.env.HOME, config: process.env.XDG_CONFIG_HOME, data: process.env.XDG_DATA_HOME, state: process.env.XDG_STATE_HOME, cache: process.env.XDG_CACHE_HOME, tmp: process.env.TMPDIR });", + "process.stdout.write(JSON.stringify({ event: \"result\", result: { conversation_id: \"fake\", status: \"SUCCESS\", response, num_turns: 1 } }));" ].join("\n")); await chmod(command, 0o700); try { diff --git a/src/pi/cliEngineRun.ts b/src/pi/cliEngineRun.ts index 73f439e..ec516a1 100644 --- a/src/pi/cliEngineRun.ts +++ b/src/pi/cliEngineRun.ts @@ -1,4 +1,4 @@ -import { createCliSessionFactory, type CliEngineKind, type CliEngineOptions } from "./cliSession.js"; +import { AGY_MAX_TOOL_TURNS, createCliSessionFactory, type CliEngineKind, type CliEngineOptions } from "./cliSession.js"; export interface EngineRunResult { readonly durationMs: number; @@ -14,7 +14,7 @@ export const runEngineDetailed = async ( ): Promise => { const startedAt = Date.now(); const options: CliEngineOptions = engine === "agy" - ? { engine, maxToolTurns: 1, timeoutMs: 180_000, toolAccess: "none" } + ? { engine, maxToolTurns: AGY_MAX_TOOL_TURNS, timeoutMs: 180_000 } : { engine }; const { session } = await createCliSessionFactory(options)({ cwd: paths.workspacePath, diff --git a/src/pi/cliEngineSpawn.ts b/src/pi/cliEngineSpawn.ts index 9adf28f..f6af688 100644 --- a/src/pi/cliEngineSpawn.ts +++ b/src/pi/cliEngineSpawn.ts @@ -19,6 +19,45 @@ export const renderCodexArgs = ( ): string[] => [...(options.commandArgs ?? []), "exec", "--sandbox", sandbox, "--skip-git-repo-check", "--color", "never", "-C", cwd, "-c", `mcp_servers.daimon.url=${endpoint}`, "-"]; +/** + * AGY's headless invocation. + * + * `--output-format stream-json` is unconditional: it is the only shape that + * carries `result.usage`, and an AGY turn that is not metered is an AGY turn + * whose subscription cost is invisible (see `agyHeadlessResult.ts`). + * + * `--dangerously-skip-permissions` is required for tools to work at all. AGY's + * default headless permission mode is `request-review`, and there is no + * reviewer inside a container, so a tool call can never be approved without it; + * the live probe that proved AGY's MCP tool calling ran with exactly this flag + * and reported `permission_mode: always-proceed`. Every Daimon CLI agent mounts + * at least the protected bash tool (`piHarness.ts`), so there is no AGY wake + * for which this is unnecessary and no conditional worth forking behavior over. + * + * SECURITY: the flag also auto-approves AGY's own ~50 built-in tools — + * `run_command`, `write_to_file`, the browser tools — none of which Daimon + * mediates. Inside the container that is the exposure Codex already has + * (`--sandbox danger-full-access`), but unlike Grok there is no kernel-enforced + * per-agent profile confining it to this agent's own workspace and runtime + * home. `DAIMON_AGY_SANDBOX=1` opts into AGY's own terminal-restricting + * sandbox; it is off by default only because its interaction with the MCP + * transport could not be verified without spending the operator's subscription + * quota, and shipping an unverified default that silently disables every tool + * would be worse than shipping the exposure with this note on it. + */ +export const renderAgyArgs = ( + options: Pick, + prompt: string, + sandbox: boolean = process.env.DAIMON_AGY_SANDBOX === "1" +): string[] => [ + ...assertSafeAgyCommandArgs(options.commandArgs), + "--print", prompt, + "--output-format", "stream-json", + "--dangerously-skip-permissions", + ...(sandbox ? ["--sandbox"] : []), + ...(options.timeoutMs === undefined ? [] : ["--print-timeout", `${options.timeoutMs}ms`]) +]; + export const spawnEngine = ( options: CliEngineOptions, prompt: string, @@ -48,10 +87,24 @@ export const spawnEngine = ( const args = options.engine === "grok" ? [...renderGrokSandboxArgs(options.commandArgs, options.grokSandboxProfile ?? GROK_STRICT_SANDBOX_PROFILE), "--always-approve", "--no-subagents", "--single", prompt, "--no-memory", "--disable-web-search", "--cwd", input.cwd, "--output-format", "streaming-messages-json"] - : [...(options.commandArgs ?? []), "--print", prompt, ...(options.timeoutMs === undefined ? [] : ["--print-timeout", `${options.timeoutMs}ms`])]; + : renderAgyArgs(options, prompt); return trackCliChild(spawn(command, args, { cwd: input.cwd, env, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] })); }; +/** + * AGY's output format, permission mode, sandbox, and session continuity are + * Daimon-owned, exactly as Grok's sandbox and approval flags are. A caller + * cannot re-open the permission boundary, silence the metering stream, or turn + * a per-wake cold process into a resumed conversation through `commandArgs`. + */ +const assertSafeAgyCommandArgs = (args: readonly string[] | undefined): readonly string[] => { + const values = args ?? []; + if (values.some((value) => /^(?:--dangerously-skip-permissions|--sandbox|--output-format|--print|--prompt|--prompt-interactive|--mode|--continue|--conversation|-p|-i|-c)(?:=|$)/u.test(value))) { + throw new Error("AGY security-boundary arguments are Daimon-owned"); + } + return values; +}; + const assertSafeGrokCommandArgs = (args: readonly string[] | undefined): readonly string[] => { const values = args ?? []; if (values.some((value) => /^(?:--sandbox|--always-approve|--permission-mode|--leader-socket)(?:=|$)/u.test(value))) { diff --git a/src/pi/cliMcpRegistration.test.ts b/src/pi/cliMcpRegistration.test.ts new file mode 100644 index 0000000..f725b3d --- /dev/null +++ b/src/pi/cliMcpRegistration.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { renderAgyArgs } from "./cliEngineSpawn.js"; +import { + DAIMON_MCP_SERVER_NAME, + renderAgyMcpAddArgs, + renderAgyMcpRemoveArgs, + renderGrokMcpAddArgs, + renderGrokMcpRemoveArgs +} from "./cliMcpRegistration.js"; + +test("Grok's registration arguments are unchanged by the AGY generalization", () => { + assert.deepEqual(renderGrokMcpAddArgs([], "strict", "http://127.0.0.1:1/mcp"), + ["--sandbox", "strict", "mcp", "add", "--transport", "http", "--scope", "project", "daimon", "http://127.0.0.1:1/mcp"]); + assert.deepEqual(renderGrokMcpRemoveArgs([], "strict"), + ["--sandbox", "strict", "mcp", "remove", "--scope", "project", "daimon"]); +}); + +test("AGY registers the per-wake endpoint as an http server, flags before the name", () => { + const args = renderAgyMcpAddArgs([], "http://127.0.0.1:54321/mcp"); + assert.deepEqual(args, ["mcp", "add", "--type", "http", DAIMON_MCP_SERVER_NAME, "http://127.0.0.1:54321/mcp"]); + // `agy mcp add` rejects a flag placed after ; the endpoint is last. + assert.equal(args.indexOf(DAIMON_MCP_SERVER_NAME) > args.indexOf("--type"), true); + assert.deepEqual(renderAgyMcpRemoveArgs([]), ["mcp", "remove", DAIMON_MCP_SERVER_NAME]); +}); + +test("AGY always runs headless in the metered stream format with tool approval", () => { + assert.deepEqual(renderAgyArgs({ timeoutMs: 180_000 }, "PROMPT", false), [ + "--print", "PROMPT", + "--output-format", "stream-json", + "--dangerously-skip-permissions", + "--print-timeout", "180000ms" + ]); + assert.deepEqual(renderAgyArgs({}, "PROMPT", true).includes("--sandbox"), true); + assert.deepEqual(renderAgyArgs({}, "PROMPT", false).includes("--sandbox"), false); +}); + +test("AGY's permission, output-format and continuity flags are Daimon-owned", () => { + for (const injected of [ + "--dangerously-skip-permissions", + "--sandbox", + "--output-format=text", + "--print", + "--continue", + "-c", + "--conversation", + "--mode" + ]) { + assert.throws( + () => renderAgyArgs({ commandArgs: [injected] }, "PROMPT", false), + /AGY security-boundary arguments are Daimon-owned/u, + injected + ); + } + assert.deepEqual(renderAgyArgs({ commandArgs: ["--effort", "high"] }, "P", false).slice(0, 2), ["--effort", "high"]); +}); diff --git a/src/pi/cliMcpRegistration.ts b/src/pi/cliMcpRegistration.ts new file mode 100644 index 0000000..83d5993 --- /dev/null +++ b/src/pi/cliMcpRegistration.ts @@ -0,0 +1,122 @@ +import { spawn, type ChildProcess } from "node:child_process"; + +import { readChild } from "./cliChildOutput.js"; +import { terminateChild, trackCliChild } from "./cliProcess.js"; +import { renderGrokSandboxArgs } from "./cliEngineSpawn.js"; + +/** + * Per-wake MCP endpoint registration for the CLI engines that cannot take the + * endpoint on their own command line. + * + * Codex takes `-c mcp_servers.daimon.url=` per invocation and needs + * nothing here. Grok and AGY are both config-file driven, so Daimon registers + * the ephemeral endpoint before the turn and removes it afterwards, through + * each CLI's own `mcp add`/`mcp remove` subcommands. + * + * The registration is deliberately performed by the engine CLI rather than by + * writing its config file directly: the file format belongs to the engine, and + * a format change absorbed by `mcp add` would silently disable every tool if + * Daimon hand-wrote it instead. + */ +export type CliMcpRegistration = Readonly<{ close: () => Promise }>; + +export type CliMcpRegistrationInput = Readonly<{ + addArgs: readonly string[]; + command: string; + cwd: string; + env: NodeJS.ProcessEnv; + failureClassifier?: (diagnostic: string) => Error | undefined; + onChild: (child: ChildProcess) => void; + onChildSettled: (child: ChildProcess) => void; + removeArgs: readonly string[]; + secretValues: readonly string[]; + verify?: () => Promise; +}>; + +/** + * `swept` decides whether the session's dispose path may kill this child. + * + * The *add* is swept: a dispose that arrives while the endpoint is still being + * registered should tear that child down with everything else. The *remove* is + * deliberately not, because dispose is precisely the caller that is waiting on + * it to finish — sweeping it makes the session terminate the cleanup it is + * awaiting, and the resulting `CLI engine exited SIGTERM` is then reported as a + * failed `stop()` for a wake that was cancelled on purpose. It stays bounded + * without the sweep: `readChild`'s 30s deadline aborts and terminates it, and + * `trackCliChild` still owns it for process exit. + */ +const runRegistrationCommand = async ( + input: CliMcpRegistrationInput, + args: readonly string[], + swept: boolean +): Promise => { + const child = trackCliChild(spawn(input.command, args, { + cwd: input.cwd, + env: input.env, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"] + })); + if (swept) input.onChild(child); + try { + await readChild(child, 30_000, input.secretValues, { + ...(input.failureClassifier === undefined ? {} : { failureClassifier: input.failureClassifier }) + }); + } finally { + // An engine CLI may let an auxiliary process outlive its leader. Do not + // release the tracked setup child until its detached group is quiescent. + await terminateChild(child); + if (swept) input.onChildSettled(child); + } +}; + +/** + * Registers the endpoint, returning a single-shot remover. + * + * `verify` is the caller's production authority (the Grok sandbox profile + * check); it runs before both the add and the remove so a registration can + * never be issued against an unverified engine. + */ +export const registerCliMcpServer = async ( + input: CliMcpRegistrationInput +): Promise => { + await input.verify?.(); + await runRegistrationCommand(input, input.addArgs, true); + let closePromise: Promise | undefined; + return { + close: (): Promise => closePromise ??= (async () => { + await input.verify?.(); + await runRegistrationCommand(input, input.removeArgs, false); + })() + }; +}; + +/** The MCP server name both engines register Daimon's per-wake endpoint under. */ +export const DAIMON_MCP_SERVER_NAME = "daimon" as const; + +export const renderGrokMcpAddArgs = (commandArgs: readonly string[] | undefined, profile: string, endpoint: string): string[] => + [...renderGrokSandboxArgs(commandArgs, profile), "mcp", "add", "--transport", "http", "--scope", "project", DAIMON_MCP_SERVER_NAME, endpoint]; + +export const renderGrokMcpRemoveArgs = (commandArgs: readonly string[] | undefined, profile: string): string[] => + [...renderGrokSandboxArgs(commandArgs, profile), "mcp", "remove", "--scope", "project", DAIMON_MCP_SERVER_NAME]; + +/** + * `agy mcp add --type http `. + * + * AGY has no per-invocation MCP flag, so this writes into the config file AGY + * reads at `$HOME/.gemini/config/mcp_config.json`. That is safe to do per wake + * because Daimon already gives every AGY agent its own `HOME` + * (`runtimeHomePath`, see `cliChildEnvironment`): two agents in one + * organization cannot collide, and a developer's real `~/.gemini` is never on + * the path a runtime child sees. `add` is an upsert, so a stale entry left by a + * crashed wake is replaced rather than duplicated. + * + * No `--header` is passed: Daimon's tool endpoint is an unauthenticated + * loopback listener on an ephemeral port, exactly as Codex and Grok receive it. + * Adding a bearer header here would put a secret in `argv` and in a file on the + * agent's durable runtime home for no gain. + */ +export const renderAgyMcpAddArgs = (commandArgs: readonly string[] | undefined, endpoint: string): string[] => + [...(commandArgs ?? []), "mcp", "add", "--type", "http", DAIMON_MCP_SERVER_NAME, endpoint]; + +export const renderAgyMcpRemoveArgs = (commandArgs: readonly string[] | undefined): string[] => + [...(commandArgs ?? []), "mcp", "remove", DAIMON_MCP_SERVER_NAME]; diff --git a/src/pi/cliSession.test.ts b/src/pi/cliSession.test.ts index d331f37..69f9e52 100644 --- a/src/pi/cliSession.test.ts +++ b/src/pi/cliSession.test.ts @@ -178,7 +178,6 @@ test("CLI engine failures include bounded redacted diagnostics", async () => { engine: "agy", maxToolTurns: 1, timeoutMs: 10_000, - toolAccess: "none", redactedEnvironmentNames: [tokenEnv] })({ cwd: root }); await assert.rejects(session.prompt("fail"), (error: unknown) => { @@ -211,7 +210,7 @@ test("CLI stdout cap counts multibyte replies and quiesces descendants", async ( ].join("\n")); try { const child = spawnEngine({ - command: process.execPath, commandArgs: [engine], engine: "agy", maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" + command: process.execPath, commandArgs: [engine], engine: "agy", maxToolTurns: 1, timeoutMs: 10_000 }, "overflow", { cwd: root }, undefined); await assert.rejects(readChild(child, 10_000, []), (error: unknown) => { assert.ok(error instanceof Error); @@ -258,13 +257,13 @@ test("protected host control variables never reach Codex, Grok, or AGY children" process.env[unrelatedEnv] = "must-never-reach-engine"; process.env[modelEnv] = "must-never-reach-engine"; const probe = path.join(root, "probe.mjs"); - await writeFile(probe, `#!/usr/bin/env node\nconst text = [process.env.${controlEnv} ?? "absent", process.env.${unrelatedEnv} ?? "absent", process.env.${modelEnv} ?? "absent", process.env.CODEX_HOME ?? process.env.GROK_HOME ?? process.env.ANTIGRAVITY_CLI_HOME ?? "missing", process.env.DAIMON_WAKE_ID ?? "absent"].join("|"); const stream = (value) => [{ type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: value }] } }, { type: "result", subtype: "success", is_error: false, result: value, stop_reason: "end_turn", session_id: "fake" }].map(JSON.stringify).join("\\n"); process.stdout.write(process.argv.includes("--single") ? stream(text) : text);`); + await writeFile(probe, `#!/usr/bin/env node\nconst text = [process.env.${controlEnv} ?? "absent", process.env.${unrelatedEnv} ?? "absent", process.env.${modelEnv} ?? "absent", process.env.CODEX_HOME ?? process.env.GROK_HOME ?? process.env.ANTIGRAVITY_CLI_HOME ?? "missing", process.env.DAIMON_WAKE_ID ?? "absent"].join("|"); const stream = (value) => [{ type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: value }] } }, { type: "result", subtype: "success", is_error: false, result: value, stop_reason: "end_turn", session_id: "fake" }].map(JSON.stringify).join("\\n"); const agy = (value) => JSON.stringify({ event: "result", result: { conversation_id: "fake", status: "SUCCESS", response: value, num_turns: 1, usage: { input_tokens: 11, output_tokens: 2, thinking_tokens: 1, cache_read_tokens: 0, total_tokens: 13 } } }); process.stdout.write(process.argv.includes("--single") ? stream(text) : process.argv.includes("--output-format") ? agy(text) : text);`); await chmod(probe, 0o700); try { for (const engine of ["codex", "grok", "agy"] as const) { const engineHomePath = path.join(root, engine, engine === "codex" ? ".codex" : engine === "grok" ? ".grok" : ".antigravity-cli"); const options = engine === "agy" - ? { engine, command: probe, commandArgs: [], maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" as const, redactedEnvironmentNames: [controlEnv], engineHomePath } + ? { engine, command: probe, commandArgs: [], maxToolTurns: 1, timeoutMs: 10_000 as const, redactedEnvironmentNames: [controlEnv], engineHomePath } : { engine, command: probe, commandArgs: [], maxToolTurns: 1, timeoutMs: 10_000, redactedEnvironmentNames: [controlEnv], engineHomePath }; const { session } = await createCliSessionFactory(options)({ cwd: root, runtimeHomePath: path.join(root, engine) }); session.bindWake?.({ id: "moltnet:msg_1", kind: "message", text: "probe" }); @@ -296,7 +295,7 @@ test("disposing a CLI session kills a stubborn process group before returning", await chmod(stubborn, 0o700); try { const { session } = await createCliSessionFactory({ - engine: "agy", command: stubborn, commandArgs: [], maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" + engine: "agy", command: stubborn, commandArgs: [], maxToolTurns: 1, timeoutMs: 10_000 })({ cwd: root }); const running = session.prompt("hold"); void running.catch(() => undefined); diff --git a/src/pi/cliSession.ts b/src/pi/cliSession.ts index 12703da..45b14aa 100644 --- a/src/pi/cliSession.ts +++ b/src/pi/cliSession.ts @@ -19,9 +19,17 @@ import { cliChildEnvironment } from "./cliEnvironment.js"; import { GROK_STRICT_SANDBOX_PROFILE, renderCodexArgs, - renderGrokSandboxArgs, spawnEngine } from "./cliEngineSpawn.js"; +import { + registerCliMcpServer, + renderAgyMcpAddArgs, + renderAgyMcpRemoveArgs, + renderGrokMcpAddArgs, + renderGrokMcpRemoveArgs, + type CliMcpRegistration +} from "./cliMcpRegistration.js"; +import { decodeAgyHeadlessTurn, type AgyTurnUsage } from "./agyHeadlessResult.js"; import { decodeGrokHeadlessResult } from "./grokHeadlessResult.js"; import { terminateChild, trackCliChild } from "./cliProcess.js"; import type { PiSessionLike } from "./piAgentHandle.js"; @@ -29,6 +37,27 @@ import type { PiSessionFactoryInput } from "./piHarness.js"; export type CliEngineKind = "agy" | "codex" | "grok"; +/** + * The per-wake tool-call bound for AGY, and the one place it is decided. + * + * Codex and Grok leave this `undefined` — their only bound is the wake + * deadline. AGY does not get that treatment, for a measured reason: its + * terminal usage frame is the *sum* over the turn's model steps, and each tool + * step resends the whole context. The live capture is 13,796 total tokens for a + * tool-free turn and 45,381 for a turn with a single call against an + * empty-schema tool — one call roughly triples the wake. An unbounded loop on + * the one engine whose subscription quota is currently the operator's only + * working credential is a cost hazard, not a capability. + * + * 16 is chosen so a realistic wake is never truncated (read a few declared MCP + * tools, then send one or two Moltnet messages) while the worst case is bounded + * at roughly a quarter-million tokens instead of "whatever fits in 180s". The + * bound degrades gracefully rather than failing the wake: exceeding it returns + * `McpToolTurnLimitError` to the model as a tool *error*, so the agent can still + * finish its turn and reply. + */ +export const AGY_MAX_TOOL_TURNS = 16; + export type CliEngineOptions = { readonly commandArgs?: readonly string[]; readonly command?: string; @@ -48,13 +77,17 @@ export type CliEngineOptions = { readonly verifyGrokSandbox?: () => Promise; readonly grokSandboxProfile?: string; readonly grokBrokerTurn?: (prompt: string, mcpEndpoint: string, signal: AbortSignal) => Promise; + /** + * Advisory per-turn metering sink for the engines whose headless stream + * reports token usage and that do not run behind the Grok engine broker + * (which meters its own turns). It never fails a turn that published. + */ + readonly onTurnUsage?: (usage: AgyTurnUsage) => Promise; } & ({ readonly engine: "codex" | "grok"; } | { readonly dbusSessionBusAddress?: string; - /** AGY has no MCP client. Selecting this state explicitly permits tool-free participation. */ readonly engine: "agy"; - readonly toolAccess: "none"; }); export type CliSessionInput = { @@ -158,53 +191,6 @@ const startMcp = async ( } }; -type GrokRegistration = { close: () => Promise }; - -const grokCommand = async ( - args: readonly string[], cwd: string, env: NodeJS.ProcessEnv, command: string, secretValues: readonly string[], - onChild: (child: ChildProcess) => void, onChildSettled: (child: ChildProcess) => void -): Promise => { - const child = trackCliChild(spawn(command, args, { - cwd, - env, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"] - })); - onChild(child); - try { - await readChild(child, 30_000, secretValues, { - failureClassifier: classifyGrokAuthenticationDiagnostic - }); - } finally { - // The Grok CLI may let an auxiliary process outlive its leader. Do not - // release the tracked setup child until its detached group is quiescent. - await terminateChild(child); - onChildSettled(child); - } -}; - -const addGrokServer = async ( - endpoint: string, cwd: string, env: NodeJS.ProcessEnv, command: string, commandArgs: readonly string[], secretValues: readonly string[], - onChild: (child: ChildProcess) => void, onChildSettled: (child: ChildProcess) => void, - profile: string, verifySandbox: (() => Promise) | undefined -): Promise => { - await verifySandbox?.(); - await grokCommand( - [...renderGrokSandboxArgs(commandArgs, profile), "mcp", "add", "--transport", "http", "--scope", "project", "daimon", endpoint], - cwd, env, command, secretValues, onChild, onChildSettled - ); - let closePromise: Promise | undefined; - return { - close: (): Promise => closePromise ??= (async () => { - await verifySandbox?.(); - await grokCommand( - [...renderGrokSandboxArgs(commandArgs, profile), "mcp", "remove", "--scope", "project", "daimon"], - cwd, env, command, secretValues, onChild, onChildSettled - ); - })() - }; -}; - class CliSession implements PiSessionLike { private readonly listeners = new Set(); private readonly setupChildren = new Set(); @@ -212,7 +198,7 @@ class CliSession implements PiSessionLike { private activeChild: ChildProcess | undefined; private activeMount: { close: () => Promise } | undefined; private activeBrokerTurn: AbortController | undefined; - private grokRegistration: GrokRegistration | undefined; + private mcpRegistration: CliMcpRegistration | undefined; private disposePromise: Promise | undefined; private wakeId: string | undefined; @@ -240,36 +226,58 @@ class CliSession implements PiSessionLike { ]); const stagedCredentialSecrets = await this.options.credentialSecretValues?.() ?? []; const secretValues = [...environmentSecretValues, ...stagedCredentialSecrets]; - const needsMcp = this.options.engine !== "agy"; let mount: { endpoint: string; close: () => Promise } | undefined; - let registration: GrokRegistration | undefined; + let registration: CliMcpRegistration | undefined; + let turnUsage: AgyTurnUsage | undefined; let child: ChildProcess | undefined; let output: string | undefined; let cleanupFailure: unknown; let promptFailure: unknown; try { - mount = needsMcp - ? await startMcp(this.input.customTools ?? [], this.options.maxToolTurns, deadline, this.options.onToolsMounted, (started) => { - this.activeMount = started; - if (this.disposed) void started.close(); - }) - : undefined; + // Every CLI engine mounts the same tool surface. AGY used to be the one + // exception (`toolAccess: "none"`), which is exactly what made an AGY + // agent unable to reach Moltnet or a declared MCP server, and so unable + // to take part in an organization at all. + mount = await startMcp(this.input.customTools ?? [], this.options.maxToolTurns, deadline, this.options.onToolsMounted, (started) => { + this.activeMount = started; + if (this.disposed) void started.close(); + }); this.ensureLive(); if (this.options.engine === "grok" && this.options.grokBrokerTurn !== undefined && mount !== undefined) { const controller=new AbortController();this.activeBrokerTurn=controller; try{output=await this.options.grokBrokerTurn(`${this.options.identityPrompt ?? ""}${text}`,mount.endpoint,controller.signal);}finally{if(this.activeBrokerTurn===controller)this.activeBrokerTurn=undefined;} } else { - if (this.options.engine === "grok" && mount !== undefined) { + if ((this.options.engine === "grok" || this.options.engine === "agy") && mount !== undefined) { await this.options.verifyExecutable?.(); - registration = await addGrokServer(mount.endpoint, this.input.cwd, cliChildEnvironment([ - ...(this.options.redactedEnvironmentNames ?? []), - ...(this.input.daimonSecretEnvironmentNames ?? []) - ], this.input.runtimeHomePath, { engine: this.options.engine, executablePath: this.options.command, engineHomePath: this.options.engineHomePath }), this.options.command ?? "grok", this.options.commandArgs ?? [], secretValues, (setupChild) => { - this.setupChildren.add(setupChild); - }, (setupChild) => this.setupChildren.delete(setupChild), - this.options.grokSandboxProfile ?? GROK_STRICT_SANDBOX_PROFILE, - this.options.verifyGrokSandbox); - this.grokRegistration = registration; + const profile = this.options.grokSandboxProfile ?? GROK_STRICT_SANDBOX_PROFILE; + const grok = this.options.engine === "grok"; + registration = await registerCliMcpServer({ + addArgs: grok + ? renderGrokMcpAddArgs(this.options.commandArgs, profile, mount.endpoint) + : renderAgyMcpAddArgs(this.options.commandArgs, mount.endpoint), + removeArgs: grok + ? renderGrokMcpRemoveArgs(this.options.commandArgs, profile) + : renderAgyMcpRemoveArgs(this.options.commandArgs), + command: this.options.command ?? this.options.engine, + cwd: this.input.cwd, + env: cliChildEnvironment([ + ...(this.options.redactedEnvironmentNames ?? []), + ...(this.input.daimonSecretEnvironmentNames ?? []) + ], this.input.runtimeHomePath, { + ...(this.options.engine === "agy" && this.options.dbusSessionBusAddress !== undefined + ? { dbusSessionBusAddress: this.options.dbusSessionBusAddress } + : {}), + engine: this.options.engine, + executablePath: this.options.command, + engineHomePath: this.options.engineHomePath + }), + ...(grok ? { failureClassifier: classifyGrokAuthenticationDiagnostic } : {}), + onChild: (setupChild) => { this.setupChildren.add(setupChild); }, + onChildSettled: (setupChild) => this.setupChildren.delete(setupChild), + secretValues, + ...(grok && this.options.verifyGrokSandbox !== undefined ? { verify: this.options.verifyGrokSandbox } : {}) + }); + this.mcpRegistration = registration; } this.ensureLive(); await this.options.verifyRuntimePaths?.(); @@ -286,7 +294,12 @@ class CliSession implements PiSessionLike { await this.options.verifyRuntimePaths?.(); await this.options.verifyExecutable?.(); const childOutput = await outputPromise; - output = this.options.engine === "grok" ? decodeGrokHeadlessResult(childOutput) : childOutput; + if (this.options.engine === "grok") output = decodeGrokHeadlessResult(childOutput); + else if (this.options.engine === "agy") { + const decoded = decodeAgyHeadlessTurn(childOutput); + output = decoded.text; + turnUsage = decoded.usage; + } else output = childOutput; } } catch (error) { promptFailure = error; @@ -295,7 +308,7 @@ class CliSession implements PiSessionLike { if (this.activeChild === child) this.activeChild = undefined; let registrationClosed = registration === undefined; cleanupFailure = await captureCleanup(cleanupFailure, async () => { await registration?.close(); registrationClosed = true; }); - if (registrationClosed && this.grokRegistration === registration) this.grokRegistration = undefined; + if (registrationClosed && this.mcpRegistration === registration) this.mcpRegistration = undefined; cleanupFailure = await captureCleanup(cleanupFailure, async () => { await mount?.close(); }); if (this.activeMount === mount) this.activeMount = undefined; } @@ -325,6 +338,12 @@ class CliSession implements PiSessionLike { stopReason: "stop", timestamp: Date.now() }, toolResults: [] } satisfies CliTurnEnd); + // Meter only after the turn has published, and never let metering failure + // rewrite a turn that succeeded: the same ordering rule the Grok engine + // broker states in `finishBrokerTurnWithUsage`. + if (turnUsage !== undefined) { + await this.options.onTurnUsage?.(turnUsage).catch(() => undefined); + } } public dispose(): void { @@ -347,7 +366,7 @@ class CliSession implements PiSessionLike { cleanupFailure = await captureCleanup(cleanupFailure, async () => { if (this.activeChild !== undefined) await terminateChild(this.activeChild); }); this.activeBrokerTurn?.abort(); cleanupFailure = await captureCleanup(cleanupFailure, () => Promise.all([...this.setupChildren].map((child) => terminateChild(child))).then(() => undefined)); - cleanupFailure = await captureCleanup(cleanupFailure, async () => { await this.grokRegistration?.close(); }); + cleanupFailure = await captureCleanup(cleanupFailure, async () => { await this.mcpRegistration?.close(); }); cleanupFailure = await captureCleanup(cleanupFailure, async () => { await this.activeMount?.close(); }); if (cleanupFailure !== undefined) throw cleanupFailure; } @@ -368,4 +387,4 @@ export const createCliSessionFactory = (options: CliEngineOptions) => async ( }; export { runEngine, runEngineDetailed, type EngineRunResult } from "./cliEngineRun.js"; -export { renderCodexArgs, spawnEngine } from "./cliEngineSpawn.js"; +export { renderAgyArgs, renderCodexArgs, spawnEngine } from "./cliEngineSpawn.js"; diff --git a/src/pi/cliSessionOutput.test.ts b/src/pi/cliSessionOutput.test.ts index 06b0d78..2dc92ac 100644 --- a/src/pi/cliSessionOutput.test.ts +++ b/src/pi/cliSessionOutput.test.ts @@ -26,8 +26,7 @@ test("verbose progress stderr is drained without invalidating a bounded successf commandArgs: [engine], engine: "agy", maxToolTurns: 1, - timeoutMs: 10_000, - toolAccess: "none" + timeoutMs: 10_000 }, "verbose", { cwd: root }, undefined); assert.equal(await readChild(child, 10_000, []), "valid assistant reply"); } finally { @@ -50,8 +49,7 @@ test("failed verbose stderr retains only a redacted bounded diagnostic tail", as commandArgs: [engine], engine: "agy", maxToolTurns: 1, - timeoutMs: 10_000, - toolAccess: "none" + timeoutMs: 10_000 }, "verbose", { cwd: root }, undefined); await assert.rejects(readChild(child, 10_000, [secret]), (error: unknown) => { assert.ok(error instanceof Error); @@ -78,7 +76,7 @@ test("redacts a 2000-byte exact secret before retaining a failed stderr tail", a try { const child = spawnEngine({ command: process.execPath, commandArgs: [engine], engine: "agy", - maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" + maxToolTurns: 1, timeoutMs: 10_000 }, "verbose", { cwd: root }, undefined); await assert.rejects(readChild(child, 10_000, [secret]), (error: unknown) => { assert.ok(error instanceof Error); diff --git a/src/pi/cliSessionProcess.test.ts b/src/pi/cliSessionProcess.test.ts index 095d5ce..937b347 100644 --- a/src/pi/cliSessionProcess.test.ts +++ b/src/pi/cliSessionProcess.test.ts @@ -18,7 +18,7 @@ test("terminates a process group after its leader has exited", async (context) = const leader = path.join(root, "leader.mjs"); await writeFile(leader, `import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000)"], { stdio: "ignore" }); child.unref(); writeFileSync(${JSON.stringify(descendant)}, String(child.pid));`); try { - const child = spawnEngine({ engine: "agy", command: process.execPath, commandArgs: [leader], maxToolTurns: 1, timeoutMs: 10_000, toolAccess: "none" }, "exit", { cwd: root }, undefined); + const child = spawnEngine({ engine: "agy", command: process.execPath, commandArgs: [leader], maxToolTurns: 1, timeoutMs: 10_000 }, "exit", { cwd: root }, undefined); await readChild(child, 10_000, []); await waitForFile(descendant); const pid = Number(await readFile(descendant, "utf8")); diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md index 1b15cf7..d802479 100644 --- a/src/runtime/AGENTS.md +++ b/src/runtime/AGENTS.md @@ -27,7 +27,23 @@ the provider's fail-open built-in profile with an exact custom profile denying the realm, bootstrap, and peer roots, and requires a kernel-enforcement event before every Grok setup, turn, and cleanup process. `organizationRuntimeReadiness.ts` composes portable credential preparation, -AGY realm readiness, and physical path authority before any agent starts. +AGY realm readiness, and physical path authority before any agent starts. AGY +fails closed on enrolment: `verifyAgySubscriptionEnrollment` runs there at host +start and again through `prepareEngineReadiness` before and after every wake, +so an unenrolled realm or an unopenable keyring refuses the agent with "run the +Daimon AGY bootstrap command" rather than producing credential-less turns. + +All three engines now get the same per-wake MCP tool surface. AGY reaches it +through `../pi/cliMcpRegistration.ts` (`agy mcp add --type http` into the +agent's own `$HOME/.gemini/config/mcp_config.json`, removed again after the +turn) rather than a command-line flag, because AGY has no equivalent of Codex's +`-c mcp_servers.daimon.url=`. `AGY_MAX_TOOL_TURNS` in `../pi/cliSession.ts` is +the only place its per-wake tool-call bound is decided. + +`turnUsageLedger.ts` is engine-neutral: the Grok broker appends through +`finishBrokerTurnWithUsage`, and AGY — which has no broker — appends through the +session's `onTurnUsage` sink wired in `engineDispatcher.ts`. Codex stays +uninstrumented and must never be recorded as zero usage. `testRuntimeSubprocess.ts` is an unexported, explicit-test-only JSONL process surface for exercising the real control, schedule, and acceptance paths with a controlled clock and deterministic scripted cognition. Its ephemeral loopback diff --git a/src/runtime/agyToolCognition.test.ts b/src/runtime/agyToolCognition.test.ts new file mode 100644 index 0000000..a59b149 --- /dev/null +++ b/src/runtime/agyToolCognition.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, open, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createRequire } from "node:module"; + +import { startOrganizationRuntimeEngine } from "./engineDispatcher.js"; +import { TURN_USAGE_LEDGER_PATH_ENV, TURN_USAGE_LEDGER_VERSION } from "./turnUsageLedger.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +const require_ = createRequire(import.meta.url); +const sdkClient = require_.resolve("@modelcontextprotocol/sdk/client/index.js"); +const sdkHttp = require_.resolve("@modelcontextprotocol/sdk/client/streamableHttp.js"); + +/** + * A stand-in for the AGY CLI that behaves the way the live binary was measured + * to behave: it records every invocation, answers `mcp add`/`mcp remove`/ + * `models`/`--version` locally, and on a `--print` turn it actually *connects* + * to the endpoint it was registered against and lists the tools there. Nothing + * here fakes the interesting step: the endpoint under test is the real + * per-wake `StreamableHTTPServerTransport` the session started, reached over + * loopback by the child process the dispatcher spawned. + */ +const agyStub = (logPath: string): string => `#!/usr/bin/env node +import { appendFileSync, readFileSync } from "node:fs"; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(logPath)}, JSON.stringify(args) + "\\n"); +if (args[0] !== "--print") process.exit(0); +const lines = readFileSync(${JSON.stringify(logPath)}, "utf8").split("\\n").filter(Boolean).map(JSON.parse); +const added = lines.filter((entry) => entry[0] === "mcp" && entry[1] === "add").at(-1); +if (added === undefined) { process.stderr.write("agy was never registered against an endpoint"); process.exit(3); } +const { Client } = await import(${JSON.stringify(sdkClient)}); +const { StreamableHTTPClientTransport } = await import(${JSON.stringify(sdkHttp)}); +const client = new Client({ name: "agy-stub", version: "0.0.0" }); +await client.connect(new StreamableHTTPClientTransport(new URL(added.at(-1)))); +const listed = await client.listTools(); +await client.close(); +process.stdout.write(JSON.stringify({ + event: "result", + result: { + conversation_id: "stub", status: "SUCCESS", + response: listed.tools.map((tool) => tool.name).sort().join(","), + num_turns: 1, + usage: { input_tokens: 44937, output_tokens: 444, thinking_tokens: 305, cache_read_tokens: 0, total_tokens: 45381 } + } +})); +`; + +const seedAgyAuth = async (root: string): Promise => { + const directory = path.join(root, "runtime", "agy", ".antigravity-cli"); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(path.dirname(directory), 0o700); + const handle = await open(path.join(directory, "antigravity-oauth-token"), "w", 0o600); + try { await handle.writeFile("stub"); } finally { await handle.close(); } +}; + +const agyAgent = (root: string): OrganizationRuntimeAgentConfig => ({ + id: "agy-agent", + name: "agy", + instructions: "Reply.", + workspacePath: path.join(root, "workspace", "agy"), + runtimeHomePath: path.join(root, "runtime", "agy"), + engine: { kind: "agy" }, + moltnet: { + cliPath: "/usr/local/bin/moltnet", + configPath: path.join(root, "workspace", "agy", ".moltnet", "config.json"), + networks: [{ id: "news", rooms: ["lobby"], dms: false }] + } +} as OrganizationRuntimeAgentConfig); + +test("an AGY agent reaches Daimon's declared cognition tools over its own MCP endpoint, and the turn is metered", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-agy-tools-")); + const priorPath = process.env.PATH; + const priorRun = process.env.NOOPOLIS_RUN_ID; + const priorLedger = process.env[TURN_USAGE_LEDGER_PATH_ENV]; + const log = path.join(root, "agy-invocations.jsonl"); + const ledger = path.join(root, "usage.jsonl"); + try { + const command = path.join(root, "agy"); + await writeFile(command, agyStub(log)); + await chmod(command, 0o700); + await seedAgyAuth(root); + process.env.PATH = `${root}${path.delimiter}${priorPath ?? ""}`; + process.env.NOOPOLIS_RUN_ID = "agy-tools-test"; + process.env[TURN_USAGE_LEDGER_PATH_ENV] = ledger; + + const handle = await startOrganizationRuntimeEngine(agyAgent(root), "DAIMON_AGY_TOOLS_CONTROL", undefined, "unix:path=/private/realm/bus"); + const result = await handle.wake({ id: "wake-1", kind: "manual", text: "probe" }); + await handle.stop(); + + // A: the AGY agent can see the Moltnet surface Spawnfile declared for it. + // Before this change AGY was pinned to `toolAccess: "none"` and this list + // could not exist at all. + assert.ok(result.text.split(",").includes("moltnet_send"), result.text); + + const invocations: string[][] = (await readFile(log, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line)); + // B: the endpoint reached AGY through `agy mcp add --type http`, and was + // withdrawn again once the wake ended. + const added = invocations.find((entry) => entry[0] === "mcp" && entry[1] === "add"); + assert.deepEqual(added?.slice(0, 5), ["mcp", "add", "--type", "http", "daimon"]); + assert.match(added?.at(-1) ?? "", /^http:\/\/127\.0\.0\.1:\d+\/mcp$/u); + assert.ok(invocations.some((entry) => entry[0] === "mcp" && entry[1] === "remove" && entry[2] === "daimon"), JSON.stringify(invocations)); + + const turn = invocations.find((entry) => entry[0] === "--print"); + assert.ok(turn?.includes("--dangerously-skip-permissions"), JSON.stringify(turn)); + assert.deepEqual(turn?.slice(2, 4), ["--output-format", "stream-json"]); + + // C: the turn is on the same ledger `spawnfile usage` reads, labelled agy. + const recorded = JSON.parse((await readFile(ledger, "utf8")).trim()); + assert.deepEqual(recorded, { + v: TURN_USAGE_LEDGER_VERSION, + agent: "agy-agent", + wake: "wake-1", + engine: "agy", + at: recorded.at, + input: 44_937, output: 444, cache_read: 0, cache_write: 0, + total: 45_381, calls: 1, notional_usd: 0, complete: true + }); + } finally { + if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; + if (priorRun === undefined) delete process.env.NOOPOLIS_RUN_ID; else process.env.NOOPOLIS_RUN_ID = priorRun; + if (priorLedger === undefined) delete process.env[TURN_USAGE_LEDGER_PATH_ENV]; + else process.env[TURN_USAGE_LEDGER_PATH_ENV] = priorLedger; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/runtime/contractManifest.test.ts b/src/runtime/contractManifest.test.ts index 3d8ad65..71690a5 100644 --- a/src/runtime/contractManifest.test.ts +++ b/src/runtime/contractManifest.test.ts @@ -41,3 +41,19 @@ test("canonical JSON rejects values with lossy or environment-dependent encoding assert.throws(() => canonicalJson({ value: undefined })); assert.throws(() => canonicalJson("\ud800")); }); + +test("consumedConfigFields names every agent field the organization runtime schema accepts", () => { + const schemaAgentFields = Object.keys( + RUNTIME_CONTRACT_MANIFEST.organizationRuntimeConfigV2Schema.properties.agents.items.properties + ).sort(); + const declaredAgentFields = [...new Set( + RUNTIME_CONTRACT_MANIFEST.consumedConfigFields + .filter((field) => field.startsWith("agents[].")) + .map((field) => field.slice("agents[].".length).split(".")[0]) + )].sort(); + assert.deepEqual( + schemaAgentFields.filter((field) => !declaredAgentFields.includes(field)), + [], + "every agent property the runtime parses must be listed in consumedConfigFields" + ); +}); From b33fc72ccf1b48015a76b07f92bf42c3d02f4ef9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:02:42 +0200 Subject: [PATCH 18/24] ci: bound every job with a timeout --- .github/workflows/ci.yml | 1 + .github/workflows/publish.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b791a86..ebcd363 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: jobs: test: + timeout-minutes: 25 runs-on: ubuntu-latest steps: - name: Check out diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dae3d1e..cc785b6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,7 @@ concurrency: jobs: publish: + timeout-minutes: 20 runs-on: ubuntu-latest steps: - name: Check out From 2f2ab3dd2239d3012a0c951a0e925cbb41a47195 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:20:01 +0200 Subject: [PATCH 19/24] ci: name every job after what it verifies --- .github/workflows/ci.yml | 1 + .github/workflows/publish.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebcd363..085298f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: jobs: test: + name: harness + runtime tests timeout-minutes: 25 runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cc785b6..5ec209e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,7 @@ concurrency: jobs: publish: + name: publish @noopolis/daimon to npm timeout-minutes: 20 runs-on: ubuntu-latest steps: From 65f39788f8e5c90db0d0edc72d542f08390104cf Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:28:16 +0200 Subject: [PATCH 20/24] docs: require every change to land through a pull request --- AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9ed59c5..37b9b5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,3 +25,23 @@ deployment. Daimon owns only the per-agent runtime boundary. policy, recall, or MCP. - Pi-specific logic belongs under `src/pi/`. - Examples should be runnable with `npm run e2e:pi-agent`. + +## Branches and pull requests + +**Never commit to `main`.** Every change lands through a pull request, without +exception — including one-line fixes, CI configuration, documentation, and +version bumps. Work on a branch, push it, open the PR, and let CI run. + +Direct commits to `main` bypass the checks that catch what local runs do not. +A zero-byte receipt store, a package that ships without its native binary, and +a two-week-red pipeline all reached `main` in this ecosystem while every local +gate was green — CI found them the first time it ran over the code. + +- Branch names describe the change: `feat/…`, `fix/…`, `ci/…`, `docs/…`. +- Commit messages are conventional and single-line (`feat:`, `fix:`, `docs:`, + `ci:`, `chore:`, `refactor:`, `test:`). +- Never add co-author lines, sign-offs, or AI attributions. +- Commit as you go rather than in one batch at the end, so history shows how + the work progressed. +- Merge with a merge commit rather than a squash when the individual commits + carry meaning; squashing collapses that history irreversibly. From 9f166a4af674508945cdc8aef02637456b37791c Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 16:40:15 +0200 Subject: [PATCH 21/24] fix(test-runtime): bound child teardown and stub the Moltnet CLI --- src/runtime/fixtures/testMoltnetCli.mjs | 55 +++++++++++++++++++ src/runtime/testRuntimeSubprocess.test.ts | 67 +++++++++++++++++++++-- 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100755 src/runtime/fixtures/testMoltnetCli.mjs diff --git a/src/runtime/fixtures/testMoltnetCli.mjs b/src/runtime/fixtures/testMoltnetCli.mjs new file mode 100755 index 0000000..c5c7afe --- /dev/null +++ b/src/runtime/fixtures/testMoltnetCli.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * A stand-in for the `moltnet` CLI, implementing only the send contract + * `testRuntimeMoltnetActions.ts` actually invokes: + * + * moltnet send --config --network --target --text + * env DAIMON_WAKE_ID= + * stdout: {"accepted":true,"message_id":"..."} + * + * The test used to exec `../moltnet/bin/moltnet` — a 30 MB binary built by hand + * in a SIBLING REPOSITORY and gitignored there. That path exists on a developer + * box that happens to have built it and cannot exist in CI, which checks out + * daimon alone, so the test failed on Linux for a reason unrelated to daimon. + * Depending on a sibling checkout also breaks the ecosystem rule that these + * repositories are independent. + * + * This asserts the invocation contract rather than merely tolerating it: a + * missing flag, a missing DAIMON_WAKE_ID, or an undeclared network exits + * non-zero, so the test still fails if daimon stops calling the CLI correctly. + */ +import { readFileSync } from "node:fs"; + +const args = process.argv.slice(2); +const fail = (message) => { process.stderr.write(`${message}\n`); process.exit(2); }; +if (args[0] !== "send") fail(`unsupported moltnet subcommand: ${args[0]}`); + +const flags = new Map(); +for (let index = 1; index < args.length; index += 2) { + if (!args[index].startsWith("--") || args[index + 1] === undefined) fail(`malformed flag near ${args[index]}`); + flags.set(args[index].slice(2), args[index + 1]); +} +for (const required of ["config", "network", "target", "text"]) { + if (!flags.has(required)) fail(`missing --${required}`); +} +// The real CLI is told which wake it is acting for; losing it would silently +// decouple the outbound message from the turn that produced it. +if (!process.env.DAIMON_WAKE_ID) fail("missing DAIMON_WAKE_ID"); + +const config = JSON.parse(readFileSync(flags.get("config"), "utf8")); +if (config.version !== "moltnet.client.v1") fail("unsupported client config version"); +const attachment = (config.attachments ?? []).find((item) => item.network_id === flags.get("network")); +if (!attachment) fail(`network ${flags.get("network")} is not declared in the client config`); + +const response = await fetch(`${attachment.base_url}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + network_id: flags.get("network"), + target: flags.get("target"), + parts: [{ kind: "text", text: flags.get("text") }] + }) +}); +const body = await response.text(); +if (!response.ok) fail(`moltnet send rejected with ${response.status}: ${body}`); +process.stdout.write(`${body}\n`); diff --git a/src/runtime/testRuntimeSubprocess.test.ts b/src/runtime/testRuntimeSubprocess.test.ts index ddfe4ea..7f68286 100644 --- a/src/runtime/testRuntimeSubprocess.test.ts +++ b/src/runtime/testRuntimeSubprocess.test.ts @@ -5,7 +5,7 @@ import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; import { createInterface } from "node:readline"; -import test from "node:test"; +import test, { after } from "node:test"; const entrypoint = path.resolve("src/runtime/testRuntimeSubprocess.ts"); const config = { @@ -72,7 +72,7 @@ test("explicit test runtime supports a bounded container HTTP bind", async () => } finally { await rm(root, { recursive: true, force: true }); } }); -test("scripted cognition uses the real Moltnet CLI path to address a declared outbound wake", async () => { +test("scripted cognition drives the Moltnet CLI send contract to address a declared outbound wake", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "daimon-test-runtime-action-")); const senderRoot = path.join(directory, "sender"); const recipientRoot = path.join(directory, "recipient"); await Promise.all([chmod(directory, 0o700), mkdtemp(`${senderRoot}-`), mkdtemp(`${recipientRoot}-`)]); @@ -94,14 +94,20 @@ test("scripted cognition uses the real Moltnet CLI path to address a declared ou await writeFile(clientConfig, JSON.stringify({ version: "moltnet.client.v1", agent: { name: "Alpha", runtime: "daimon" }, attachments: [{ agent_name: "Alpha", auth: { mode: "none" }, base_url: `http://127.0.0.1:${address.port}`, member_id: "alpha", network_id: "test-network", runtime: "daimon", rooms: [{ id: "dispatch" }] }] }), { mode: 0o600 }); const senderStart = await sender.command({ type: "start", acceptance_store_path: await actualSenderRoot, config, control_token: "sender-token", now_ms: 0, cognition_actions: [{ delivery_id: "moltnet:incoming_1", network_id: "test-network", target: "room:dispatch", text: "addressed result" }], - moltnet_cli_path: path.resolve("../moltnet/bin/moltnet"), moltnet_client_config_path: clientConfig }) as { base_url: string }; + moltnet_cli_path: path.resolve("src/runtime/fixtures/testMoltnetCli.mjs"), moltnet_client_config_path: clientConfig }) as { base_url: string }; assert.equal((await post(senderStart.base_url, { agent_id: "alpha", delivery_id: "moltnet:incoming_1", event: { version: "noopolis.daimon.wake.v2", kind: "message", text: "inbound", occurred_at: "1970-01-01T00:00:00.000Z" } }, "sender-token")).status, 202); await settles(sender, 1); await settles(recipient, 1); const senderEvidence = await sender.command({ type: "snapshot" }) as { action_receipts: Array<{ target: string }> }; assert.deepEqual(senderEvidence.action_receipts.map((item) => item.target), ["room:dispatch"]); await sender.command({ type: "stop" }); await recipient.command({ type: "stop" }); await Promise.all([sender.close(), recipient.close(), new Promise((resolve, reject) => bridge.close((error) => error ? reject(error) : resolve()))]); - } finally { await rm(directory, { recursive: true, force: true }); } + } finally { + // Closed here, not only on the success path: when an assertion above threw, + // both children were left alive with their stdin never ended, and their + // still-open pipes kept the test runner's event loop from ever draining. + await Promise.allSettled([sender.close(), recipient.close()]); + await rm(directory, { recursive: true, force: true }); + } }); test("scripted cognition calls only an attested compiled MCP server and tool", async () => { @@ -124,8 +130,57 @@ test("subprocess entrypoint is unavailable without explicit test mode", async () assert.notEqual(code, 0); assert.match(error, /requires DAIMON_EXPLICIT_TEST_RUNTIME=1/); }); +/** + * Every runtime child still believed to be alive. + * + * A test that throws before its own `close()` used to leave the child sleeping + * on a stdin that is never ended; its pipes then keep this runner's event loop + * alive and the whole job hangs until the CI timeout kills it — 24 minutes of + * silence for one failed assertion. The `after` hook below is the backstop, so + * no future test can reintroduce that by forgetting a `finally`. + */ +const liveRuntimes = new Set(); + +after(() => { + const leaked = [...liveRuntimes]; + for (const child of leaked) child.kill("SIGKILL"); + liveRuntimes.clear(); + // Loud, never silent: a leaked child means a test's teardown is broken, and + // that is exactly the defect that cost a CI job. + assert.equal(leaked.length, 0, `${leaked.length} test runtime child process(es) leaked; a test did not close its runtime`); +}); + +/** + * Waits for the child to exit, escalating rather than waiting forever. + * + * The unbounded `await` this replaces turned any stuck child into a hung job. + * A kill is reported, never swallowed: reaching SIGTERM means the child did not + * exit on stdin close, which is a real teardown defect and must fail the test. + */ +async function exitWithin(child: ChildProcessWithoutNullStreams): Promise<{ code: number | null; killed: "none" | "SIGTERM" | "SIGKILL" }> { + // Already reaped: `exit` has fired and will never fire again, so awaiting it + // would hang forever. `close()` is deliberately called twice on the success + // path — once explicitly, once by the `finally` safety net — so this has to + // be idempotent. + if (child.exitCode !== null || child.signalCode !== null) { + return { code: child.exitCode, killed: "none" }; + } + const exited = new Promise((resolve) => child.once("exit", resolve)); + const sleep = async (ms: number): Promise<"timeout"> => await new Promise((resolve) => setTimeout(() => resolve("timeout"), ms)); + if (await Promise.race([exited.then(() => "exit" as const), sleep(10_000)]) === "exit") { + return { code: await exited, killed: "none" }; + } + child.kill("SIGTERM"); + if (await Promise.race([exited.then(() => "exit" as const), sleep(5_000)]) === "exit") { + return { code: await exited, killed: "SIGTERM" }; + } + child.kill("SIGKILL"); + return { code: await exited, killed: "SIGKILL" }; +} + function runtime(): { command(value: unknown): Promise; close(): Promise } { const child = spawn(process.execPath, ["--import", "tsx", entrypoint], { env: { ...process.env, DAIMON_EXPLICIT_TEST_RUNTIME: "1" }, stdio: ["pipe", "pipe", "pipe"] }); + liveRuntimes.add(child); const lines = createInterface({ input: child.stdout }); const replies: Array<(value: unknown) => void> = []; lines.on("line", (line) => replies.shift()?.(JSON.parse(line))); @@ -136,7 +191,9 @@ function runtime(): { command(value: unknown): Promise; close(): Promis }), close: async () => { child.stdin.end(); - const code = await new Promise((resolve) => child.once("exit", resolve)); + const { code, killed } = await exitWithin(child); + liveRuntimes.delete(child); + if (killed !== "none") throw new Error(`test runtime had to be killed with ${killed}; it did not exit when its command stream closed`); if (code !== 0) throw new Error(`test runtime exited ${code}`); } }; From d59c965d3b4d4b8ef3bb3d2427ac324525431137 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 17:03:40 +0200 Subject: [PATCH 22/24] fix(broker): survive a client that disconnects mid-turn --- src/runtime/engineBrokerService.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/runtime/engineBrokerService.ts b/src/runtime/engineBrokerService.ts index da96103..9faa5c4 100644 --- a/src/runtime/engineBrokerService.ts +++ b/src/runtime/engineBrokerService.ts @@ -17,7 +17,26 @@ export async function startEngineBrokerServiceWithIdentity(broker:EngineBrokerSe try{await chmod(socketPath,0o600);await verifySocket(socketPath,expectedUid);}catch(error){await closeServer(server,connections);await unlink(socketPath).catch(()=>undefined);throw error;} let closed=false;return {close:async()=>{if(closed)return;closed=true;for(const controller of active.values())controller.abort();const results=await Promise.allSettled([closeServer(server,connections),broker.close()]);await unlink(socketPath).catch(()=>undefined);const failures=results.flatMap((result)=>result.status==="rejected"?[result.reason]:[]);if(failures.length)throw new AggregateError(failures,"engine broker service shutdown failed");},health:()=>({activeTurns:active.size,socketPath})}; } -function handle(socket:Socket,broker:EngineBrokerServiceEngine,active:Map):void{const decoder=new EngineBrokerFrameDecoder();let started=false,owned:Readonly<{turnId:string;controller:AbortController}>|undefined;socket.once("close",()=>owned?.controller.abort());socket.on("data",(chunk)=>{try{for(const value of decoder.push(chunk)){const request=parseEngineBrokerRequest(value);if(request.kind==="health"){if(started)throw new Error();started=true;const ready=broker.readiness();if(ready.providerProxyPort!==43123||ready.mcpFacadePort!==43124||ready.registrations<1||ready.credentialStale||!ready.realmLease||!ready.workerIsolation)throw new Error();return send(socket,{version:request.version,kind:"ready",requestId:request.requestId,brokerUid:2100,providerProxyPort:43123,mcpFacadePort:43124,registrations:ready.registrations,credentialStale:false,realmLease:true,workerIsolation:true});}if(request.kind==="cancel_turn"){active.get(request.turnId)?.abort();continue;}if(started||active.has(request.turnId))throw new Error();started=true;const controller=new AbortController();owned={turnId:request.turnId,controller};active.set(request.turnId,controller);socket.write(encodeEngineBrokerFrame({version:request.version,kind:"accepted",requestId:request.requestId,turnId:request.turnId}));void broker.turn(request.agentId,request.wakeId,request.prompt,request.mcpEndpoint,controller.signal).then((result)=>send(socket,{version:request.version,kind:"completed",requestId:request.requestId,turnId:request.turnId,...result}),(error:unknown)=>send(socket,{version:request.version,kind:"failed",requestId:request.requestId,turnId:request.turnId,code:error instanceof EngineBrokerTurnFailure?error.code:controller.signal.aborted?"cancelled":"engine_failed",...(error instanceof EngineBrokerTurnFailure&&error.diagnostic?{diagnostic:error.diagnostic}:{})})).finally(()=>{if(active.get(request.turnId)===controller)active.delete(request.turnId);owned=undefined;});}}catch{socket.destroy();}});} +/** + * A connection error is a client that went away, never a reason to take the + * broker down with it. + * + * Without this listener an accepted socket that emits `error` had none, and + * node turns an unhandled `error` on a stream into an uncaught exception — so a + * single departing client killed the whole service and every other in-flight + * turn with it. That is reachable from the ordinary cancellation path: the + * client destroys its socket on abort, this side aborts the turn, the engine + * rejects, and this side then writes a `failed` frame to a peer that is already + * gone. On Linux that write/read races an RST and surfaces as ECONNRESET; macOS + * closes cleanly enough that the error never arrives, which is why this was + * invisible until the suite ran in a container. + * + * Treated exactly like `close`: abort whatever turn this connection owned and + * drop the socket. The turn's own result is already unreachable — its only + * reader has disconnected. + */ +function handleSocketError(socket:Socket,owned:()=>Readonly<{turnId:string;controller:AbortController}>|undefined):void{socket.on("error",()=>{owned()?.controller.abort();socket.destroy();});} +function handle(socket:Socket,broker:EngineBrokerServiceEngine,active:Map):void{const decoder=new EngineBrokerFrameDecoder();let started=false,owned:Readonly<{turnId:string;controller:AbortController}>|undefined;handleSocketError(socket,()=>owned);socket.once("close",()=>owned?.controller.abort());socket.on("data",(chunk)=>{try{for(const value of decoder.push(chunk)){const request=parseEngineBrokerRequest(value);if(request.kind==="health"){if(started)throw new Error();started=true;const ready=broker.readiness();if(ready.providerProxyPort!==43123||ready.mcpFacadePort!==43124||ready.registrations<1||ready.credentialStale||!ready.realmLease||!ready.workerIsolation)throw new Error();return send(socket,{version:request.version,kind:"ready",requestId:request.requestId,brokerUid:2100,providerProxyPort:43123,mcpFacadePort:43124,registrations:ready.registrations,credentialStale:false,realmLease:true,workerIsolation:true});}if(request.kind==="cancel_turn"){active.get(request.turnId)?.abort();continue;}if(started||active.has(request.turnId))throw new Error();started=true;const controller=new AbortController();owned={turnId:request.turnId,controller};active.set(request.turnId,controller);socket.write(encodeEngineBrokerFrame({version:request.version,kind:"accepted",requestId:request.requestId,turnId:request.turnId}));void broker.turn(request.agentId,request.wakeId,request.prompt,request.mcpEndpoint,controller.signal).then((result)=>send(socket,{version:request.version,kind:"completed",requestId:request.requestId,turnId:request.turnId,...result}),(error:unknown)=>send(socket,{version:request.version,kind:"failed",requestId:request.requestId,turnId:request.turnId,code:error instanceof EngineBrokerTurnFailure?error.code:controller.signal.aborted?"cancelled":"engine_failed",...(error instanceof EngineBrokerTurnFailure&&error.diagnostic?{diagnostic:error.diagnostic}:{})})).finally(()=>{if(active.get(request.turnId)===controller)active.delete(request.turnId);owned=undefined;});}}catch{socket.destroy();}});} function send(socket:Socket,response:EngineBrokerResponse):void{if(!socket.destroyed)socket.end(encodeEngineBrokerFrame(response));} async function removeOwnedSocket(file:string,uid:number):Promise{try{const entry=await lstat(file);if(!entry.isSocket()||Number(entry.uid)!==uid)throw new Error("unsafe broker socket");await unlink(file);}catch(error){if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;}} async function verifySocket(file:string,uid:number):Promise{const entry=await lstat(file);if(!entry.isSocket()||Number(entry.uid)!==uid||(Number(entry.mode)&0o777)!==0o600)throw new Error("unsafe broker socket");} From c28eeea662bb5f98de7a68a1a42c3fc2d2533880 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 17:03:40 +0200 Subject: [PATCH 23/24] fix(pi): treat an empty process group as settled on Linux --- src/pi/cliProcess.ts | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/pi/cliProcess.ts b/src/pi/cliProcess.ts index b948423..195c53a 100644 --- a/src/pi/cliProcess.ts +++ b/src/pi/cliProcess.ts @@ -48,28 +48,47 @@ const parseLinuxProcessState = (stat: string): LinuxProcessState | undefined => return fields.length >= 3 && Number.isInteger(group) ? { state: fields[0]!, group } : undefined; }; -const linuxGroupHasLiveMember = async (pgid: number, procRoot = "/proc"): Promise => { +/** + * What a `/proc` snapshot says about a process group. + * + * `"settled"` covers BOTH "every member is an exited zombie" and "the group has + * no members at all". Those are the same fact — nothing in the group can still + * execute — but they used to be reported differently: an empty scan returned + * `undefined`, which the caller read as "not settled", so a group that had + * finished disappearing was declared unfinished and cleanup threw. + * + * That is reachable whenever the last member exits between `groupExists` (which + * sees it, zombies included) and this scan. It only bites on Linux, because the + * `/proc` path does not run anywhere else — and containers are Linux, so it is + * the platform that actually matters. + * + * `"unknown"` is reserved for a genuine read failure, where refusing to call the + * group settled is the safe answer. + */ +type LinuxGroupScan = "live" | "settled" | "unknown"; + +const linuxGroupScan = async (pgid: number, procRoot = "/proc"): Promise => { let entries: string[]; - try { entries = await readdir(procRoot); } catch { return undefined; } - let found = false; + try { entries = await readdir(procRoot); } catch { return "unknown"; } for (const entry of entries) { if (!/^\d+$/u.test(entry)) continue; try { const processState = parseLinuxProcessState(await readFile(`${procRoot}/${entry}/stat`, "utf8")); if (processState?.group !== pgid) continue; - found = true; - if (processState.state !== "Z" && processState.state !== "X") return true; + if (processState.state !== "Z" && processState.state !== "X") return "live"; } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") return undefined; + // ENOENT is the process exiting mid-scan, which is exactly what settling + // looks like; anything else means the snapshot cannot be trusted. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return "unknown"; } } - return found ? false : undefined; + return "settled"; }; const groupSettled = async (pgid: number): Promise => { if (!groupExists(pgid)) return true; if (process.platform !== "linux") return false; - return await linuxGroupHasLiveMember(pgid) === false; + return await linuxGroupScan(pgid) === "settled"; }; const signalGroup = (pgid: number, signal: NodeJS.Signals): boolean => { From c24f3d806b8916f3b96b918cfdb2f97964d7f1c5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 17:29:26 +0200 Subject: [PATCH 24/24] test(runtime): derive CLI health waits from the test timeout --- src/runtime/cli.test.ts | 68 ++++++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/src/runtime/cli.test.ts b/src/runtime/cli.test.ts index 8f54a5e..4c6e273 100644 --- a/src/runtime/cli.test.ts +++ b/src/runtime/cli.test.ts @@ -4,7 +4,7 @@ import { chmod, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/p import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; -import test from "node:test"; +import test, { type TestContext } from "node:test"; import { ORGANIZATION_RUNTIME_VERSION } from "./organizationRuntime.js"; import { parseOrganizationRuntimeCliArguments, runOrganizationRuntimeCli } from "./cli.js"; @@ -27,7 +27,7 @@ test("CLI rejects an oversized config before JSON parsing", async () => { } }); -test("CLI strictly authenticates and routes a production Daimon engine", async () => { +test("CLI strictly authenticates and routes a production Daimon engine", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "daimon-runtime-cli-")); const port = await availablePort(); const tokenEnv = "DAIMON_RUNTIME_CLI_TEST_TOKEN"; @@ -64,7 +64,7 @@ test("CLI strictly authenticates and routes a production Daimon engine", async ( child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)); child.stderr?.on("data", (chunk: Buffer) => output.push(chunk)); try { - await waitForHealth(port, token, child, output); + await waitForHealth(t, port, token, child, output); assert.deepEqual(JSON.parse(await readFile(readinessReceipt, "utf8")), { version: "noopolis.daimon.readiness-receipt.v1", agents: [{ agent_id: "agent", engine: "codex" }] }); assert.equal(await readFile(runtimeAuth, "utf8"), await readFile(inboundAuth, "utf8")); assert.equal((await lstat(path.dirname(runtimeAuth))).mode & 0o777, 0o700); @@ -109,7 +109,7 @@ test("CLI strictly authenticates and routes a production Daimon engine", async ( const activityBody = await activityV2.json() as { version: string; items: Array<{ delivery_id: string }> }; assert.equal(activityBody.version, "noopolis.daimon.organization-runtime-activity.v2"); assert.equal(activityBody.items.some((item) => item.delivery_id === "delivery-1"), true); - await waitForReceipt(port, token, acceptance.acceptance_id); + await waitForReceipt(t, port, token, acceptance.acceptance_id); const conflict = await fetch(`http://127.0.0.1:${port}/v2/wakes`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ agent_id: "agent", delivery_id: "delivery-1", event: { version: "noopolis.daimon.wake.v2", kind: "manual", text: "different", occurred_at: "2026-08-17T00:00:00.000Z" @@ -144,22 +144,62 @@ async function availablePort(): Promise { return address.port; } -async function waitForHealth(port: number, token: string, child: ReturnType, output: Buffer[]): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { +const POLL_INTERVAL_MS = 20; +const GENEROUS_WAIT_MS = 60_000; + +/** + * Whether a completion wait may keep polling. + * + * Nothing below measures speed, only that something eventually happens. The CLI + * is spawned through `tsx`, so it compiles TypeScript before it can bind a port + * at all, which makes "time to healthy" a property of the machine rather than of + * the product. The fixed budget this replaces — 100 polls x 20ms = 2s — was + * therefore a cap on how slow the runner was allowed to be: measured at 978ms on + * an 18-core laptop and 1320ms on a cold 2-core container, it left almost no + * headroom, and CI blew through it at 2354ms with the process still `running` + * (alive, not crashed, simply not finished booting). + * + * The budget now ends at whichever comes first: the test's own timeout, since + * node aborts `t.signal` then and the harness `--test-timeout` governs it, or a + * generous cap that still fails a genuine hang in bounded time instead of + * stalling until the job is killed. + */ +function stillWaiting(t: TestContext, startedAt: number): boolean { + return !t.signal.aborted && Date.now() - startedAt < GENEROUS_WAIT_MS; +} + +async function waitForHealth(t: TestContext, port: number, token: string, child: ReturnType, output: Buffer[]): Promise { + const startedAt = Date.now(); + let attempts = 0, lastProbe = "never attempted"; + while (stillWaiting(t, startedAt)) { + attempts += 1; try { const response = await fetch(`http://127.0.0.1:${port}/v1/health`, { headers: { authorization: `Bearer ${token}` } }); if (response.status === 200) return; - } catch { /* process has not bound yet */ } - await new Promise((resolve) => setTimeout(resolve, 20)); + lastProbe = `HTTP ${response.status}`; + } catch (error) { lastProbe = error instanceof Error ? error.message : String(error); } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } - throw new Error(`runtime CLI did not become healthy: ${Buffer.concat(output).toString("utf8")}; exit=${child.exitCode ?? child.signalCode ?? "running"}`); + // Carry the evidence. This used to interpolate an empty output buffer, so the + // failure read "did not become healthy: ; exit=running" and said nothing about + // why — which is exactly why this took so long to diagnose from CI alone. + const text = Buffer.concat(output).toString("utf8").trim(); + throw new Error(`runtime CLI did not become healthy after ${Date.now() - startedAt}ms across ${attempts} probes; exit=${child.exitCode ?? child.signalCode ?? "running"}; last probe: ${lastProbe}; child output: ${text === "" ? "(none — the process had not logged anything yet)" : text}`); } -async function waitForReceipt(port: number, token: string, acceptanceId: string): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { +async function waitForReceipt(t: TestContext, port: number, token: string, acceptanceId: string): Promise { + const startedAt = Date.now(); + let attempts = 0, lastState = "never observed"; + while (stillWaiting(t, startedAt)) { + attempts += 1; const response = await fetch(`http://127.0.0.1:${port}/v2/wake-receipts/${acceptanceId}`, { headers: { authorization: `Bearer ${token}` } }); - if (response.status === 200 && (await response.json() as { state: string }).state === "completed") return; - await new Promise((resolve) => setTimeout(resolve, 20)); + if (response.status === 200) { + const { state } = await response.json() as { state: string }; + if (state === "completed") return; + lastState = state; + } else lastState = `HTTP ${response.status}`; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } - throw new Error("v2 acceptance did not reach a terminal result"); + // Same fixed-budget shape, and it reported no diagnostic at all. + throw new Error(`v2 acceptance ${acceptanceId} did not reach a terminal result after ${Date.now() - startedAt}ms across ${attempts} polls; last observed state: ${lastState}`); }