From da13bf56dc2207c42a5cc5b2aa18e46493a64c13 Mon Sep 17 00:00:00 2001 From: Michiel Westerbeek Date: Thu, 27 Aug 2026 09:24:53 +0000 Subject: [PATCH] Revert "Revert unreviewed detached Agent Host program (#238)" This reverts commit 61ca8559327b7d870057bcbba68dcd4da028a7ee. --- deploy/agent-host-service.test.ts | 86 + deploy/deploy.sh | 5 + deploy/install-agent-host-topology.sh | 71 + .../agent-host-signing-credential.conf | 3 + docs/agent-host-deployment.md | 85 + docs/canary-qualification.md | 97 + docs/executor-architecture.md | 25 + docs/security-model.md | 208 ++ docs/session-kernel-architecture.md | 97 + opensession-agent-host@.service | 50 + opensession-agent-host@.socket | 19 + .../canary-production-probes.test.ts | 156 ++ .../agent-host/canary-production-probes.ts | 228 +++ .../agent-host/canary-qualification.test.ts | 426 ++++ .../src/agent-host/canary-qualification.ts | 405 ++++ .../src/agent-host/driver.test.ts | 217 +++ .../src/agent-host/driver.ts | 66 + .../src/agent-host/emergency-reserve.test.ts | 87 + .../src/agent-host/emergency-reserve.ts | 175 ++ .../agent-host/generation-supervisor.test.ts | 568 ++++++ .../src/agent-host/generation-supervisor.ts | 849 ++++++++ .../src/agent-host/host.test.ts | 588 ++++++ .../opensession-server/src/agent-host/host.ts | 1595 +++++++++++++++ .../src/agent-host/ledger-accounting.test.ts | 200 ++ .../src/agent-host/ledger-accounting.ts | 181 ++ .../src/agent-host/ledger-crypto.test.ts | 68 + .../src/agent-host/ledger-crypto.ts | 231 +++ .../ledger-enospc-qualification.test.ts | 202 ++ .../src/agent-host/ledger-schema.ts | 128 ++ .../opensession-server/src/agent-host/main.ts | 45 + .../src/agent-host/registry.test.ts | 32 + .../src/agent-host/runtime.test.ts | 100 + .../src/agent-host/runtime.ts | 201 ++ .../src/agent-host/socket-framing.test.ts | 27 + .../src/agent-host/socket-framing.ts | 93 + .../src/agent-host/sqlite-ledger.test.ts | 325 +++ .../src/agent-host/sqlite-ledger.ts | 1736 +++++++++++++++++ .../agent-host/transport-integration.test.ts | 814 ++++++++ .../src/server/actor-transcript.ts | 2 +- .../src/server/agent-host-client.test.ts | 289 +++ .../src/server/agent-host-client.ts | 964 +++++++++ .../src/server/agent-host-registry.ts | 57 + .../agent-operation/authorized-query.ts | 196 ++ .../agent-operation/composition.test.ts | 436 +++++ .../src/server/agent-operation/composition.ts | 245 +++ .../deletion-coordinator.test.ts | 308 +++ .../agent-operation/deletion-coordinator.ts | 767 ++++++++ .../server/agent-operation/gateway.test.ts | 592 ++++++ .../src/server/agent-operation/gateway.ts | 686 +++++++ .../src/server/agent-operation/grants.test.ts | 305 +++ .../src/server/agent-operation/grants.ts | 572 ++++++ .../agent-operation/kernel-facade.test.ts | 222 +++ .../server/agent-operation/kernel-facade.ts | 288 +++ .../src/server/agent-operation/ledger.ts | 408 ++++ .../agent-operation/mcp-adapter.test.ts | 428 ++++ .../src/server/agent-operation/mcp-adapter.ts | 364 ++++ .../agent-operation/pi-model-adapter.test.ts | 481 +++++ .../agent-operation/pi-model-adapter.ts | 329 ++++ .../pi-model-operation.test.ts | 199 ++ .../agent-operation/pi-model-operation.ts | 540 +++++ .../agent-operation/production-probes.test.ts | 167 ++ .../agent-operation/production-probes.ts | 177 ++ .../readiness-collector.test.ts | 231 +++ .../agent-operation/readiness-collector.ts | 236 +++ .../server/agent-operation/readiness.test.ts | 212 ++ .../src/server/agent-operation/readiness.ts | 299 +++ .../server/agent-operation/service.test.ts | 298 +++ .../src/server/agent-operation/service.ts | 621 ++++++ .../session-kernel-readiness.test.ts | 211 ++ .../session-kernel-readiness.ts | 314 +++ .../agent-operation/sqlite-ledger.test.ts | 918 +++++++++ .../server/agent-operation/sqlite-ledger.ts | 1054 ++++++++++ .../agent-operation/stream-journal.test.ts | 88 + .../server/agent-operation/stream-journal.ts | 139 ++ .../agent-operation/transcript-facade.test.ts | 210 ++ .../agent-operation/transcript-facade.ts | 275 +++ .../transport/linux-peer-credentials.test.ts | 409 ++++ .../transport/linux-peer-credentials.ts | 152 ++ .../security/transport/node-socket-fd.ts | 30 + .../testing/inherited-unix-socket-child.ts | 25 + .../transport/unix-socket-security.ts | 464 +++++ .../src/server/session-kernel/actor-client.ts | 76 + .../server/session-kernel/actor-protocol.ts | 2 + .../server/session-kernel/actor-routing.ts | 13 + .../src/server/session-kernel/actor-worker.ts | 8 +- .../agent-host-supervision-protocol.ts | 200 ++ .../agent-host-supervision-signer.ts | 174 ++ .../agent-host-supervision-signing.test.ts | 203 ++ .../agent-host-supervision.test.ts | 323 +++ .../agent-operation-protocol.ts | 584 ++++++ .../session-kernel/agent-operation.test.ts | 656 +++++++ .../src/server/session-kernel/index.ts | 1 + .../src/server/session-kernel/kernel.ts | 21 + .../session-kernel/lifecycle-protocol.ts | 12 + .../server/session-kernel/store-routing.ts | 1 + .../src/server/session-kernel/store.ts | 1307 ++++++++++++- .../session-kernel/transcript-protocol.ts | 156 +- .../src/server/transcript-store.ts | 8 +- packages/core/protocol/package.json | 6 +- .../core/protocol/src/agent-host-fence.ts | 28 + .../protocol/src/agent-host-supervision.ts | 574 ++++++ packages/core/protocol/src/agent-host.test.ts | 149 ++ packages/core/protocol/src/agent-host.ts | 367 ++++ .../core/protocol/src/agent-operation.test.ts | 476 +++++ packages/core/protocol/src/agent-operation.ts | 1149 +++++++++++ packages/core/protocol/src/index.ts | 5 + scripts/check-module-side-effects.ts | 2 +- 107 files changed, 31987 insertions(+), 211 deletions(-) create mode 100644 deploy/agent-host-service.test.ts create mode 100755 deploy/install-agent-host-topology.sh create mode 100644 deploy/systemd/agent-host-unactivated/opensession-session-kernel.service.d/agent-host-signing-credential.conf create mode 100644 docs/agent-host-deployment.md create mode 100644 docs/canary-qualification.md create mode 100644 opensession-agent-host@.service create mode 100644 opensession-agent-host@.socket create mode 100644 packages/core/opensession-server/src/agent-host/canary-production-probes.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/canary-production-probes.ts create mode 100644 packages/core/opensession-server/src/agent-host/canary-qualification.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/canary-qualification.ts create mode 100644 packages/core/opensession-server/src/agent-host/driver.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/driver.ts create mode 100644 packages/core/opensession-server/src/agent-host/emergency-reserve.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/emergency-reserve.ts create mode 100644 packages/core/opensession-server/src/agent-host/generation-supervisor.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/generation-supervisor.ts create mode 100644 packages/core/opensession-server/src/agent-host/host.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/host.ts create mode 100644 packages/core/opensession-server/src/agent-host/ledger-accounting.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/ledger-accounting.ts create mode 100644 packages/core/opensession-server/src/agent-host/ledger-crypto.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/ledger-crypto.ts create mode 100644 packages/core/opensession-server/src/agent-host/ledger-enospc-qualification.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/ledger-schema.ts create mode 100644 packages/core/opensession-server/src/agent-host/main.ts create mode 100644 packages/core/opensession-server/src/agent-host/registry.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/runtime.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/runtime.ts create mode 100644 packages/core/opensession-server/src/agent-host/socket-framing.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/socket-framing.ts create mode 100644 packages/core/opensession-server/src/agent-host/sqlite-ledger.test.ts create mode 100644 packages/core/opensession-server/src/agent-host/sqlite-ledger.ts create mode 100644 packages/core/opensession-server/src/agent-host/transport-integration.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-host-client.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-host-client.ts create mode 100644 packages/core/opensession-server/src/server/agent-host-registry.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/authorized-query.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/composition.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/composition.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/deletion-coordinator.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/deletion-coordinator.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/gateway.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/gateway.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/grants.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/grants.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/kernel-facade.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/kernel-facade.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/ledger.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/mcp-adapter.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/mcp-adapter.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/pi-model-adapter.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/pi-model-adapter.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/pi-model-operation.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/pi-model-operation.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/production-probes.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/production-probes.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/readiness-collector.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/readiness-collector.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/readiness.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/readiness.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/service.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/service.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/session-kernel-readiness.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/session-kernel-readiness.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/sqlite-ledger.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/sqlite-ledger.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/stream-journal.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/stream-journal.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/transcript-facade.test.ts create mode 100644 packages/core/opensession-server/src/server/agent-operation/transcript-facade.ts create mode 100644 packages/core/opensession-server/src/server/security/transport/linux-peer-credentials.test.ts create mode 100644 packages/core/opensession-server/src/server/security/transport/linux-peer-credentials.ts create mode 100644 packages/core/opensession-server/src/server/security/transport/node-socket-fd.ts create mode 100644 packages/core/opensession-server/src/server/security/transport/testing/inherited-unix-socket-child.ts create mode 100644 packages/core/opensession-server/src/server/security/transport/unix-socket-security.ts create mode 100644 packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-protocol.ts create mode 100644 packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-signer.ts create mode 100644 packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-signing.test.ts create mode 100644 packages/core/opensession-server/src/server/session-kernel/agent-host-supervision.test.ts create mode 100644 packages/core/opensession-server/src/server/session-kernel/agent-operation-protocol.ts create mode 100644 packages/core/opensession-server/src/server/session-kernel/agent-operation.test.ts create mode 100644 packages/core/protocol/src/agent-host-fence.ts create mode 100644 packages/core/protocol/src/agent-host-supervision.ts create mode 100644 packages/core/protocol/src/agent-host.test.ts create mode 100644 packages/core/protocol/src/agent-host.ts create mode 100644 packages/core/protocol/src/agent-operation.test.ts create mode 100644 packages/core/protocol/src/agent-operation.ts diff --git a/deploy/agent-host-service.test.ts b/deploy/agent-host-service.test.ts new file mode 100644 index 0000000000..91822fd417 --- /dev/null +++ b/deploy/agent-host-service.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dir, ".."); + +function render(service: string) { + return service + .replaceAll("@@WORKING_DIRECTORY@@", repoRoot) + .replaceAll("@@BUN@@", process.execPath) + .replaceAll("@@GATEWAY_UID@@", "12345") + .replaceAll("@@HOST_UID@@", "12346"); +} + +describe("detached Agent Host deployment foundation", () => { + test("renders hardened generation service and root-owned socket templates", async () => { + const serviceTemplate = await Bun.file(resolve(repoRoot, "opensession-agent-host@.service")).text(); + const socket = await Bun.file(resolve(repoRoot, "opensession-agent-host@.socket")).text(); + const service = render(serviceTemplate); + expect(service).not.toContain("@@"); + expect(service).toContain("User=opensession-agent-host"); + expect(service).toContain("StateDirectory=opensession/agent-host/%i"); + expect(service).toContain("StateDirectoryMode=0700"); + expect(service).toContain("ExecStartPre="); + expect(service).toContain("--doctor --generation %i --expected-gateway-uid 12345 --expected-host-uid 12346"); + expect(service).toContain("RuntimeMaxSec=24h"); + expect(service).toContain("TimeoutStopSec=20s"); + expect(service).toContain("NoNewPrivileges=true"); + expect(service).toContain("ProtectSystem=strict"); + expect(service).toContain("IPAddressDeny=any"); + expect(service).not.toContain("EnvironmentFile="); + expect(service).not.toContain("agent-host-supervision-signing-key"); + expect(socket).toContain("ListenStream=/run/opensession/agent-host-%i.sock"); + expect(socket).toContain("FileDescriptorName=agent-host"); + expect(socket).toContain("SocketUser=root"); + expect(socket).toContain("SocketGroup=opensession-gateway"); + expect(socket).toContain("SocketMode=0660"); + }); + + test("systemd-analyze accepts rendered templates when available", async () => { + if (Bun.spawnSync(["sh", "-c", "command -v systemd-analyze"], { stdout: "ignore" }).exitCode !== 0) return; + const directory = await mkdtemp(join(tmpdir(), "agent-host-units-")); + try { + const service = render(await Bun.file(resolve(repoRoot, "opensession-agent-host@.service")).text()); + const socket = await Bun.file(resolve(repoRoot, "opensession-agent-host@.socket")).text(); + const servicePath = join(directory, "opensession-agent-host@.service"); + const socketPath = join(directory, "opensession-agent-host@.socket"); + await Promise.all([writeFile(servicePath, service), writeFile(socketPath, socket)]); + const result = Bun.spawnSync(["systemd-analyze", "verify", servicePath, socketPath], { stderr: "pipe", stdout: "pipe" }); + expect(new TextDecoder().decode(result.stderr)).not.toContain("Unknown key"); + expect(result.exitCode).toBe(0); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("root installer creates separate identities without activating topology", async () => { + const installer = await Bun.file(resolve(import.meta.dir, "install-agent-host-topology.sh")).text(); + const deploy = await Bun.file(resolve(import.meta.dir, "deploy.sh")).text(); + for (const identity of ["opensession-gateway", "opensession-session-kernel", "opensession-agent-host", "opensession-executor"]) + expect(installer).toContain(identity); + expect(installer).toContain("distinct UIDs"); + expect(installer).toContain("/var/lib/opensession/agent-host"); + expect(installer).not.toMatch(/systemctl\s+enable/); + expect(installer).not.toMatch(/systemctl\s+start/); + expect(deploy).toContain("install-agent-host-topology.sh"); + }); + + test("has no owned production socket or runner-host fallback", async () => { + const runtime = await Bun.file(resolve(repoRoot, "packages/core/opensession-server/src/agent-host/runtime.ts")).text(); + expect(runtime).toContain("inheritedFd: fd"); + expect(runtime).not.toContain("socketPath:"); + expect(runtime).not.toContain("runner-host"); + expect(runtime).not.toContain("gateway-local"); + }); + + test("keeps all key material in systemd credentials", async () => { + const service = await Bun.file(resolve(repoRoot, "opensession-agent-host@.service")).text(); + const signing = await Bun.file(resolve(import.meta.dir, "systemd/agent-host-unactivated/opensession-session-kernel.service.d/agent-host-signing-credential.conf")).text(); + expect(service).toContain("LoadCredential=agent-host-ledger-keyring:"); + expect(service).toContain("LoadCredential=agent-host-supervision-keyring:"); + expect(signing).toContain("LoadCredential=agent-host-supervision-signing-key:"); + expect(signing).toContain("FUTURE ACTIVATION TEMPLATE"); + }); +}); diff --git a/deploy/deploy.sh b/deploy/deploy.sh index c6876fde8b..32ccec2379 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -113,6 +113,11 @@ if [ -z "$PREVIOUS_HEAD" ]; then run_release switch "$TARGET_COMMIT" fi +# Install only the disabled, production-unwired Agent Host topology. This +# creates future service identities and root-owned directories but deliberately +# does not change the users of any currently active service or enable a socket. +"$REPO_DIR/deploy/install-agent-host-topology.sh" "$CURRENT_LINK" "$SERVICE_BUN" + # (Re)install the shared-checkout tripwire hook: warns loudly if this live # checkout ever gets switched off main (branch work must use a worktree). if [ -f "$REPO_DIR/deploy/git-hooks/post-checkout" ]; then diff --git a/deploy/install-agent-host-topology.sh b/deploy/install-agent-host-topology.sh new file mode 100755 index 0000000000..b85bcb8197 --- /dev/null +++ b/deploy/install-agent-host-topology.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Install the production-unactivated detached Agent Host identity and unit foundation. +set -euo pipefail + +[ "$(id -u)" = 0 ] || { echo "[agent-host-install] ERROR: root is required" >&2; exit 1; } +[ "$#" = 2 ] || { echo "usage: $0 " >&2; exit 2; } +WORKDIR="$1" +BUN="$2" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" + +case "$WORKDIR" in /*) ;; *) echo "[agent-host-install] ERROR: release directory must be absolute" >&2; exit 1;; esac +case "$BUN" in /*) ;; *) echo "[agent-host-install] ERROR: Bun path must be absolute" >&2; exit 1;; esac +[ -d "$WORKDIR" ] || { echo "[agent-host-install] ERROR: release directory is absent" >&2; exit 1; } +[ -x "$BUN" ] || { echo "[agent-host-install] ERROR: Bun is not executable" >&2; exit 1; } + +identities=(opensession-gateway opensession-session-kernel opensession-agent-host opensession-executor) +for identity in "${identities[@]}"; do + if getent passwd "$identity" >/dev/null; then + [ "$(getent passwd "$identity" | cut -d: -f7)" = /usr/sbin/nologin ] || { + echo "[agent-host-install] ERROR: existing $identity account is not a nologin service account" >&2; exit 1; + } + else + getent group "$identity" >/dev/null || groupadd --system "$identity" + useradd --system --gid "$identity" --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin "$identity" + fi +done + +uids=() +for identity in "${identities[@]}"; do uids+=("$(id -u "$identity")"); done +[ "$(printf '%s\n' "${uids[@]}" | sort -u | wc -l)" = "${#identities[@]}" ] || { + echo "[agent-host-install] ERROR: Open Session service identities must have distinct UIDs" >&2; exit 1; +} +GATEWAY_UID="$(id -u opensession-gateway)" +HOST_UID="$(id -u opensession-agent-host)" + +install -d -o root -g root -m 0755 /run/opensession /var/lib/opensession +install -d -o root -g root -m 0755 /etc/opensession /etc/opensession/credentials +install -d -o root -g opensession-agent-host -m 0710 \ + /var/lib/opensession/agent-host /etc/opensession/credentials/agent-host +for identity in gateway session-kernel executor; do + account="opensession-$identity" + install -d -o "$account" -g "$account" -m 0700 "/var/lib/opensession/$identity" +done + +escape_sed() { printf '%s' "$1" | sed 's/[&|]/\\&/g'; } +workdir_escaped="$(escape_sed "$WORKDIR")" +bun_escaped="$(escape_sed "$BUN")" +rendered="$(mktemp)" +trap 'rm -f "$rendered"' EXIT +sed \ + -e "s|@@WORKING_DIRECTORY@@|$workdir_escaped|g" \ + -e "s|@@BUN@@|$bun_escaped|g" \ + -e "s|@@GATEWAY_UID@@|$GATEWAY_UID|g" \ + -e "s|@@HOST_UID@@|$HOST_UID|g" \ + "$REPO_DIR/opensession-agent-host@.service" > "$rendered" +install -o root -g root -m 0644 "$rendered" /etc/systemd/system/opensession-agent-host@.service +install -o root -g root -m 0644 "$REPO_DIR/opensession-agent-host@.socket" /etc/systemd/system/opensession-agent-host@.socket +systemctl daemon-reload + +# Installation must not activate the production-unwired boundary. +if systemctl list-unit-files 'opensession-agent-host@*.socket' --state=enabled --no-legend 2>/dev/null | grep -q .; then + echo "[agent-host-install] ERROR: Agent Host socket instance is unexpectedly enabled" >&2 + exit 1 +fi +if systemctl list-units 'opensession-agent-host@*.service' 'opensession-agent-host@*.socket' --state=active --no-legend 2>/dev/null | grep -q .; then + echo "[agent-host-install] ERROR: Agent Host topology is unexpectedly active" >&2 + exit 1 +fi + +echo "[agent-host-install] installed disabled Agent Host topology" diff --git a/deploy/systemd/agent-host-unactivated/opensession-session-kernel.service.d/agent-host-signing-credential.conf b/deploy/systemd/agent-host-unactivated/opensession-session-kernel.service.d/agent-host-signing-credential.conf new file mode 100644 index 0000000000..2bb9d60863 --- /dev/null +++ b/deploy/systemd/agent-host-unactivated/opensession-session-kernel.service.d/agent-host-signing-credential.conf @@ -0,0 +1,3 @@ +# FUTURE ACTIVATION TEMPLATE. deploy/install-agent-host-topology.sh does not install it. +[Service] +LoadCredential=agent-host-supervision-signing-key:/etc/opensession/credentials/session-kernel/agent-host-supervision-signing-key.json diff --git a/docs/agent-host-deployment.md b/docs/agent-host-deployment.md new file mode 100644 index 0000000000..1aea4cca9d --- /dev/null +++ b/docs/agent-host-deployment.md @@ -0,0 +1,85 @@ +# Detached Agent Host deployment foundation + +The detached Agent Host topology is installed but **not activated**. No gateway +route selects it, no socket instance is enabled, and there is no gateway-local +or runner-host fallback. Do not enable a generation until the gateway routing, +SessionKernel signing composition, ledger calibration, and recovery policy have +all been approved. + +## Process and filesystem boundary + +A future generation is a matched pair: + +- `opensession-agent-host@.socket` creates the root-owned + `/run/opensession/agent-host-.sock`. Its mode is `0660`, owner is + root, and its group is exactly `opensession-gateway`. +- `opensession-agent-host@.service` runs as the exact + `opensession-agent-host` user. It accepts only the inherited descriptor named + `agent-host`, proves that it is a listening AF_UNIX socket, and verifies the + exact numeric `opensession-gateway` UID with `SO_PEERCRED` before reading a + protocol frame. Production never unlinks, binds, chmods, or replaces the + socket path. +- Each generation receives `/var/lib/opensession/agent-host/` as a + private `0700` `StateDirectory`. Its `recovery-ledger.sqlite` is opened by + that generation only. The ExecStartPre doctor opens and closes the same + ledger serially before the service becomes its sole writer. + +The process has a 24-hour maximum lifetime and a bounded 15-second application +drain inside systemd's 20-second stop bound. The unit uses systemd hardening +compatible with Bun JIT/FFI and SQLite. In particular, it does not claim +`MemoryDenyWriteExecute` or an untested syscall allowlist. + +## Service identities + +`deploy/install-agent-host-topology.sh` idempotently creates four distinct, +nologin system accounts and groups: + +- `opensession-gateway` +- `opensession-session-kernel` +- `opensession-agent-host` +- `opensession-executor` + +It also creates root-controlled runtime, state, and credential parents and +installs the service/socket templates. It does not change the `User=` of any +current service and does not enable or start Agent Host units. + +## Credentials + +Secrets are never accepted in argv or ordinary environment variables. A future +generation requires these root-owned source files: + +- `/etc/opensession/credentials/agent-host//ledger-keyring.json` +- `/etc/opensession/credentials/agent-host//supervision-keyring.json` + +Systemd projects them as `agent-host-ledger-keyring` and +`agent-host-supervision-keyring`. The entrypoint requires each projected file +to be a root-owned, regular, single-link `0400` file and rejects absent, +oversized, redirected, or malformed values. The ledger credential is strict +JSON with `version: 1`, one active key ID, and at most four keys. Encryption keys +are exactly 32 bytes and lookup keys are at least 32 bytes, encoded as canonical +unpadded base64url. The public supervision credential is the strict protocol-v2 +Ed25519 public keyring. + +The private supervision signing key belongs only to the future +`opensession-session-kernel` identity. The uninstalled template at +`deploy/systemd/agent-host-unactivated/opensession-session-kernel.service.d/agent-host-signing-credential.conf` +shows the only permitted systemd credential projection. Never place that key in +an Agent Host unit. Installing that drop-in is a separate production activation +step and is intentionally outside this foundation. + +Startup and doctor failures emit only a generic message. Credential contents, +paths supplied by a caller, and nested parsing errors are not logged. + +## Installation and rollout + +These files are root-deploy-managed artifacts. After review, installing them +requires the full root rollout: + +```sh +sudo deploy/deploy.sh +``` + +A light self-deploy is insufficient. The initial rollout only creates identities, +directories, and disabled unit templates. Do **not** run the full deploy merely +to test this foundation, and do not manually start, enable, or restart a unit. +Validate source changes with the focused Bun tests instead. diff --git a/docs/canary-qualification.md b/docs/canary-qualification.md new file mode 100644 index 0000000000..c0451b101e --- /dev/null +++ b/docs/canary-qualification.md @@ -0,0 +1,97 @@ +# Detached Agent Host canary qualification + +`canary-qualification.ts` is a production-unwired, import-inert qualification +harness for a detached Agent Host candidate. It does not install routes, send +signals, start processes, contact services, or deploy a generation. An operator +must provide policy-approved probes and decide separately whether a qualified +report is sufficient to activate anything. + +## Qualification plan + +The harness runs each case as one logical operation: + +1. Submit one operation constrained to `agent-host`, with infrastructure fallback + disabled and a physical retry limit of zero. +2. Wait for independent evidence that physical execution started. +3. Apply exactly one injected scenario intervention. +4. Read the visible terminal result and ACK/replay observations. +5. Read Host dispatch, physical-effect, generation, fallback, and path evidence. +6. Read exact transcript, operation, and kernel receipts. +7. Read the terminal transcript entry. + +The deterministic scenario matrix is: + +| Scenario | Required injected events | Terminal | +| ------------------------- | ------------------------------------------------------------- | --------- | +| `normal` | none | completed | +| `gateway-sigkill-restart` | gateway SIGKILL, gateway restarted | completed | +| `host-sigkill-restart` | Host SIGKILL, Host restarted | completed | +| `disconnect` | transport disconnected, transport reconnected | completed | +| `cancellation` | cancellation requested, cancellation acknowledged | cancelled | +| `key-rotation` | key rotated | completed | +| `blue-green-drain` | generation draining, generation activated, generation drained | completed | + +“SIGKILL” in this table names evidence supplied by the process probe. The +harness itself has no process or signal capability. + +## Fail-closed evidence contract + +A case qualifies only when all of the following are established: + +- exactly one logical operation and one Host dispatch; +- exactly one model effect, one MCP effect, and one Executor effect, each from a + distinct independently maintained counter; +- zero physical retries; +- exactly one visible terminal gateway result; +- exactly one transcript receipt, operation receipt, and kernel receipt, all for + the operation and terminal state; +- exactly one matching visible terminal transcript entry; +- non-empty, non-negative, monotonic ACK and replay sequences, with replay never + ahead of ACK; +- the execution generation exactly equals the non-empty active generation; +- `infrastructureFallback` is exactly `false`; +- the complete observed path list is exactly `agent-host`, excluding + `runner-host`, `direct`, mixed, and unknown paths; and +- exact, ordered intervention evidence for the selected scenario. + +Missing, duplicate, mismatched, invalid, or extra evidence fails qualification. +Every probe call is wrapped by an injected per-step deadline. A deadline or +probe error fails closed. Probe error text and operation identifiers are not +included in reports. + +If a kill leaves physical completion ambiguous, the harness returns +`indeterminate` with `AMBIGUOUS_EFFECT`. It stops collecting downstream success +evidence and never resubmits or retries the physical operation. Indeterminate is +not qualified. + +## Probe boundary + +Call `qualifyDetachedAgentHostCanary(scenario, probes)` with injected gateway, +Host, process, receipt, transcript, and deadline probes. The deadline probe owns +all clocks and timers, making qualification deterministic under tests and +keeping module import inert. + +Reports are deliberately small and redacted: + +```ts +{ + version: 1, + scenario: "host-sigkill-restart", + outcome: "qualified" | "failed" | "indeterminate", + codes: ["QUALIFIED" /* or one fixed failure code */], + redacted: true, +} +``` + +Codes are a fixed vocabulary. Do not extend the report with raw exceptions, +transcript text, receipt bodies, credentials, keys, process output, or IDs. +Store detailed evidence only in the organization-controlled system that +implements the probes. + +## Non-activation + +This harness is intentionally not exported from a server composition module and +is not called by boot, deployment, health, readiness, or routing code. Adding a +production probe implementation or using a report as an activation gate is a +separate security and rollout change. Qualification alone must not change the +active generation. diff --git a/docs/executor-architecture.md b/docs/executor-architecture.md index 4a99e56f30..2d9f7a24f7 100644 --- a/docs/executor-architecture.md +++ b/docs/executor-architecture.md @@ -156,6 +156,31 @@ development. Active hosts are still controlled directly through their private host protocol; the executor is not their parent and does not own session lifecycle. +### Agent Host execution binding + +An Agent Host turn carries an immutable Executor binding: executor and root IDs, +generation, deadline, and an opaque Agent Host access capability. That access +capability authorizes only bounded control-plane dispatch requests. It is +branded separately from an `ExecutorGrant` and is never valid at an +`ExecutorBroker` or Executor daemon. The control plane must issue a fresh, +exact operation-scoped `ExecutorGrant` for each eventual dispatch. + +A separate additive Agent operation v1 foundation now defines a distinctly +branded `AgentGatewayDispatchGrant`, non-secret model and MCP descriptors, and a +gateway receipt ledger. It remains production-unwired: the gateway does not +issue the grant, route Host operation messages, resolve provider/MCP access, or +open the ledger at boot. The grant is never persisted. Recovery must reacquire +short-lived authority while durable identity remains bound to the exact turn +fence and domain-separated descriptor/payload digests. This foundation does +not make an Agent operation an Executor operation and never accepts an +`ExecutorGrant` in its place. + +The Agent Host contracts define these boundaries but do not route production +turns or wire boot. The disabled detached process and systemd socket-activation +foundation is documented in [Agent Host deployment](agent-host-deployment.md). +Installing its privileged templates requires a full root deploy; installation +does not enable or start them. + ## Rollback compatibility The session-kernel schema has a tracked compatibility version. Before restarting diff --git a/docs/security-model.md b/docs/security-model.md index 2ed6d6778c..51882a898b 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -7,6 +7,214 @@ rule is guidance, a stripped tool or scoped token is a guarantee. This document is the full reference behind the invariant summary in [AGENTS.md](../AGENTS.md). +## Detached Agent Host boundary + +The detached Agent Host design keeps provider and MCP traffic behind the +gateway and surfaces ambiguous proxy outcomes as visible `indeterminate` +failures. Hosts and the SessionKernel use separate service users. Blue/green +Host workers have a 24-hour maximum lifetime. Capacity is dynamically admitted +with no fixed concurrent-turn count. Each turn may accumulate at most 32 MiB of +actual worst-case physical charge. Ordinary ledger growth stops at 448 MiB; the +protected 64 MiB for cancel, deletion, indeterminate, quarantine, minimal +terminal, recovery, and checkpoint work is inside the same 512 MiB physical +ceiling, not additional capacity. + +SessionKernel schema 27 provides transactional `signed_v1` supervision receipt +storage and a Node-only synchronous Ed25519 signing primitive. The untrusted V3 +claim contains only the exact fence, plan and Host identity plus a fresh Host +challenge. A trusted non-wire issuer owns service epoch, fixed lease, clock, +nonce and the single active key. Existing schema-26 receipts migrate as +`legacy_unsigned_v2`; they are never retro-signed and cannot authorize or be +replayed as signed authority. Production deliberately injects no issuer +credential, so new signed claims fail closed without affecting readiness. + +Protocol v3 now implements the production-unwired signed attach foundation. +Each physical Host connection receives a fresh one-use challenge and may attach +only after public-key-only verification of the schema-27 envelope and every +actor-issued binding. The envelope alone and operation IDs are never authority. +This is not composed into production boot or existing Pi routing. Deployment +still requires separate Host and gateway service identities and peer +credentials, private signing-key provisioning only to SessionKernel, strict +public keyring provisioning to Hosts, and a detached Host service deployment. +The current shared Ubuntu identity is explicitly not that boundary. + +An additive Linux-only Unix-socket peer-credential foundation lives under +`src/server/security/transport/`. It explicitly loads and closes libc, checks +the exact accepted socket immediately around `SO_PEERCRED`, and gates protocol +readers behind an exact numeric UID policy. Its private server wrapper can adopt +an already-listening inherited Unix descriptor without unlinking, binding, +chmodding, validating, or replacing its filesystem path. Inherited listeners +require an exact expected non-root peer UID, and production composition can fail +closed in inherited-FD-only mode. The legacy owned-path mode remains for tests +and unwired callers; it requires protected, non-symlink path components plus +exact parent/socket owner and mode policies before listening. A crash-safe +exclusive Linux `flock` must be held across stale-socket proof, removal, and +bind, preventing concurrent service instances from displacing each other's +socket inode. Importing the transport performs no work, and it has no production +boot wiring. +Future Host and SessionKernel Unix transports must reject the physical socket +before parsing bytes or allocating session state. Production cross-user +endpoints must use root-owned systemd `.socket` units and inherited listener +FDs, never service-writable socket parents. UID is the principal; PID is +audit/fencing metadata and never reusable authorization. Socket owner and mode +checks are defense in depth, not an identity substitute. Activation must use +separate service users and an exact expected non-root UID; bearer tokens, +loopback, filesystem modes, and caller names are not fallbacks when peer +verification fails. + +An import-inert encrypted Host recovery ledger v1 and conservative physical +accounting prototype now exist under `src/agent-host/`. The disabled detached +Agent Host entrypoint opens only its generation-isolated ledger after its +ExecStartPre doctor; boot, gateway routing, drivers, providers, and MCP routing +remain production-unwired and no socket instance is enabled. Recovery-bearing values use application-level +AES-256-GCM and HMAC-derived opaque lookup keys; this does not encrypt SQLite +schema, phases, bounded counters, timestamps, key IDs, or opaque keys. It does +not use SQLCipher or a custom VFS. Bun SQLite does not expose dirty-page or +checkpoint-peak attribution, so the prototype uses a deliberately conservative +page/WAL/B-tree bound and post-commit assertions rather than claiming exact +per-turn measurement. Production wiring remains blocked on calibration and a +proven checkpoint/ENOSPC emergency implementation. Processes sharing a UID can +inspect or interfere with each other and are not a security boundary. + +### Agent operation receipt foundation + +The additive Agent operation v1 protocol, gateway SQLite ledger, and schema-28 +SessionKernel admission/barrier receipts are a production-unwired foundation. +They do not execute model or MCP work, open a route, resolve credentials, or +compose the ledger at boot. The actor authorizes only a bounded durable identity +after matching the exact active signed schema-27 supervision row and registered +plan. Legacy unsigned receipts cannot authorize admission, and an authority +hash supplied without that stored signed row fails closed. A future gateway +caller must also verify the signed supervision envelope and separately branded +`AgentGatewayDispatchGrant`, recompute every domain-separated digest, and pass +kind-specific policy before physical work can begin. + +Requests and status queries bind the operation ID to the exact turn fence, +descriptor and payload digests. An operation ID alone is never authority. Model +descriptors carry only a transcript anchor and policy hash. MCP descriptors +carry only a durable tool-use reference and arguments digest. Strict decoders +reject bodies, prompts, arguments, credentials, URLs, headers, environment and +provider/account configuration recursively. Durable receipts contain bounded +identity, timestamps, normalized outcome codes, transcript destination receipt +references and digests only. Neither actor storage nor the operation ledger has +a body or arbitrary metadata column. Schema-28 actor state is strictly +`admitted -> settled | indeterminate`; gateway-only physical state remains +`prepared -> executing -> settled | indeterminate`. + +Transcript destination receipts have an import-inert exact query and Agent +reference-validation layer. Generic destination appends retain their existing +upsert semantics and reject Agent anchors, so a generic receipt cannot later be +upgraded into an Agent proof. The narrower Agent API requires an authenticated +anchor identity whose change sequence is the current transcript high-water and +whose named entries are visible at that boundary. It atomically permits only a +fresh, unique, dense, request-ordered output append. The anchor digest remains +opaque to this store; the future gateway must construct and authenticate its +canonical transcript meaning before calling this API. Recovery binds the +session, run, turn, generation, append ID, request digest and anchor, then +revalidates each referenced output row's ID, sequence, change sequence and +canonical content against the durable request digest. Later unrelated +transcript entries do not invalidate that historical proof, but changed, +missing, reordered or malformed referenced output fails closed. Receipt +queries do not write, publish, invoke hooks or change access timestamps. This +layer remains production-unwired. + +The import-inert gateway dispatch registry stores only domain-separated grant +hashes and bounded exact bindings in memory. A grant binds one operation to the +complete run fence, signed-authority identity, Host incarnation, descriptor and +payload digests, transcript anchor, adapter version, deadline and opaque gateway +policy handle. Runtime-domain crossover, expiry, capacity overflow and every +identity mismatch fail closed. A backwards clock jump clears the registry and +fails closed. Raw bearer grants and provider or MCP policy values are never +retained, persisted or exposed as doctor evidence. Expiry is pruned +synchronously on registry access, so importing the module starts no timer. This +registry is not composed into production routing yet. + +Every gateway-ledger receipt durably binds the actor-required supervisor epoch, +Host identity/generation/incarnation, exact transcript anchor and MCP tool-use +entry identity. Every terminal receipt also carries the exact bounded +SessionKernel replay material: output digest, outcome code, ordered transcript +receipt references and, for model operations, ordered pending tool-use entry +IDs. Settled and indeterminate receipts lacking this material fail strict +decoding. Recovery must first durably reserve indeterminate terminal ownership, +then re-read and authenticate that reservation from the durable row before +appending a visible entry whose destination identity is derived from it, and +finally mark the receipt indeterminate. Settlement cannot +commit after the reservation. The reservation survives restart, so recovery can +repair the actor terminal without retrying physical provider or MCP work or +leaving a false indeterminate entry after a competing settlement. + +The receipt state progression is `prepared -> executing -> settled | +indeterminate`; an executing row may additionally carry the durable terminal +reservation while transcript proof is being committed. A recovered `prepared` +operation may be reauthorized later. +A recovered `executing` operation is never retried by default. Initial +production model and MCP adapters must use unsupported reconciliation, which +produces a durable visible `indeterminate` receipt unless a later adapter +supplies exact, tested reconciliation proof. Provider request or response IDs +alone are not idempotency proof. Abort, timeout, cancellation and disconnect +also do not prove settlement. + +The production-unwired Agent Host turn socket uses exact protocol v3 with no v2 compatibility. Agent-to-gateway dispatch +capabilities and Executor operation grants have distinct canonical wire domains +and are cross-rejected at runtime, not merely separated with TypeScript brands. +Hosts receive a root descriptor rather than a raw gateway filesystem path. + +A prerequisite destination API for future Host recovery now exists only inside +the gateway. It accepts transcript destination payloads, not credentials, +provider/model/MCP configuration, or arbitrary prompts, and has no public HTTP +route or production Host routing. It rejects non-plain or non-JSON values, +unknown request keys, non-finite numbers, malformed transcript entries, and +bounded-count/byte overflows before writing. Canonical hashing uses an explicit +versioned domain and binds the exact turn fence and entries. Only the new +`transcript_destination_append` gateway operation is replay-safe; broadening the +legacy transcript operation would make ambiguous old callbacks unsafe. + +### Agent Host readiness and doctor contract + +The import-inert readiness checker in +`src/server/agent-operation/readiness.ts` is a production-unwired policy over +injected observations. It does not inspect the filesystem, open ledgers, bind a +route, start a timer, or mutate recovery state. A future doctor collector may +supply observations, but its machine response is deliberately bounded to a +contract version, admission decision, normalized route mode, fixed-vocabulary +failing check codes, and deletion, recovery, and stream-ACK capability flags. +It must not expose paths, observed UIDs, digests, key material, secrets, policy +handles, or registry contents. + +The contract fails closed unless all four gateway, Agent Host, Executor, and +SessionKernel service UIDs are distinct and non-root, and both directions of +the gateway-to-Host and Host-to-Executor Unix peer UID gates match exactly. The +active generation must have valid manifest, protocol, release, and keyring +digests that match its manifest. Activation cannot be in the future, its +deadline can be at most 24 hours after activation, and the deadline must still +be current. The retained prior observation time must be no later than the +current observation. This means a backwards clock jump, stale generation, or +stale or not-yet-valid signing public key blocks readiness. The signing public +key must be verified by the active keyring, and Host ledger encryption key +availability is mandatory. + +Before readiness can pass, the exact Host ledger schema must be open with Host +recovery complete, the exact gateway operation-ledger schema must be open with +`recoverActive` complete, and SessionKernel schema must be at least 32 with +cancellation available. Deletion, recovery, and cumulative operation stream ACK +capabilities must all be available. `routeMode` accepts exactly `legacy` or +`agent_host_only`. In `agent_host_only`, the active Host generation must be +healthy and accepting new work, never draining-only; every named grant, +operation, turn, and stream registry must report a hard bound; and +`infrastructureFallback` must be the literal boolean `false`. + +A future production boot sequence must establish keys and the verified active +generation first, then open and recover SessionKernel and its cancellation +surface, open and recover the Host ledger, run gateway operation-ledger +`recoverActive`, and only then mark the Host active and healthy. The gateway may +publish `agent_host_only` admission only after the checker passes. A failing +readiness result blocks **new admission only**. It never reroutes, falls back, +reattaches, drains, cancels, or otherwise steers an existing session. Existing +sessions remain owned by the generation and route that admitted them. + +This contract does not wire that sequence, the doctor route, Agent Host boot, +or production routing. + ## Automation least-privilege Automation runs (especially event-triggered ones like support-ticket triage) diff --git a/docs/session-kernel-architecture.md b/docs/session-kernel-architecture.md index c33b59d967..d75a4b0e67 100644 --- a/docs/session-kernel-architecture.md +++ b/docs/session-kernel-architecture.md @@ -321,6 +321,103 @@ never enter actor payloads. Removing the remaining create-plan compatibility authority is the next creation cutover; the presence or absence of a plan file is not actor lifecycle evidence. +## Agent operation receipts (unwired foundation) + +Schema 28 adds actor-owned Agent operation admission, transcript barriers, and +terminal receipts to the existing Agent operation v1 protocol and import-inert +gateway ledger. The internal facade is deliberately not composed at boot and +has no Host transport, provider/MCP adapter, credential resolution, live key, +or production operation route. It therefore performs no provider, MCP, socket, +executor, or transcript I/O and creates no executable production authority. + +Each admit, settle, indeterminate, or exact query is one synchronous actor +reduction and one short SQLite transaction. Admission binds the exact turn +fence, operation identity and kind, descriptor and payload digests, adapter +identity, transcript input anchor, registered plan, and the current active +schema-27 signed supervision receipt. A caller-supplied authority hash is never +accepted independently of that stored signed row. Legacy unsigned receipts do +not authorize operations. + +A turn has at most one admitted nonterminal operation. A model terminal declares +an ordered, bounded, unique set of pending tool-use entry IDs contained in its +terminal transcript receipts. MCP admission binds the exact next declared entry, +so distinct calls may proceed model, MCP, MCP. A successor anchor must +cumulatively cover every required transcript entry and change sequence, and the +next model is blocked until every declared MCP operation has settled. Physical +prepared and executing phases remain gateway-ledger state. Actor state +is only admitted, settled, or indeterminate. Indeterminate is visible terminal +state and blocks continuation until a future explicit actor-owned recovery +policy exists. Exact duplicate requests replay their original durable receipt; +identity or terminal crossover quarantines the session. + +Terminal receipts and active operations are retained for at least seven days. +Expiry-aware pruning never removes an active operation or the latest dependency +for a turn. Per-turn and per-session receipt limits bound storage without a +fixed session or turn count, while a separate monotonic operation high-water +survives pruning and restart. Session deletion removes receipts and high-water +in the same tombstone transaction. + +The shared gateway ledger requires an exact session/operation primary key plus +kind, full turn fence, plan and authority hashes, descriptor and physical +payload digests, and adapter ID/version. A mismatch is atomically quarantined +without replacing the original identity. Exact terminal replay returns the +canonical durable receipt. `prepared` means no physical invocation was allowed +to start and may be reauthorized after recovery. Once `executing` commits, +recovery requires explicit adapter proof; the default and initial adapter +contract is reconciliation unsupported and settles the row visibly as +`indeterminate`, never as a retry. The SessionKernel now admits and settles only these durable authority facts. +Gateway execution remains unwired. No actor mailbox is held across physical +provider, MCP, gateway-ledger, or transcript work. + +## Detached Agent Host supervision + +Schema 26 is an additive migration from live schemas 24 and 25 and raises the +normal `user_version` rollback floor, so an older actor refuses the migrated +store. Its receipt and plan backfill is transactional, crash-resumable, and +validates every canonical authority before raising that floor. +It adds a v2 Agent Host supervision authority consumed by the exact +production-unwired Agent Host wire protocol v3. Before any claim, a short typed SessionKernel +reduction registers the exact current run/generation, turn ID, and canonical +plan hash once. Exact registration replays and any mismatch fails closed. A +second short claim reduction must match that actor-owned plan, consumes a Host +challenge and nonce once, and monotonically advances a per-session supervisor +high-water mark. It binds the stable Host ID, Host generation and process +incarnation to the current kernel service epoch. Exact retries return the same +canonical immutable payload and bytes. A fresh challenge lets the same Host +generation recover after either its process incarnation or the kernel service +epoch changes; the higher supervisor epoch fences old control. Lower Host +generations and changed Host IDs remain stale. + +The actor stores only bounded supervision metadata. It does not store prompts, +transcripts, provider/model configuration, MCP payloads, or credentials, and it +performs no provider, executor, model, socket, or signing work. Superseded and settled receipts remain replayable through their lease and clock +skew. The actor prunes only expired non-active receipts before enforcing its +fixed capacity; active and unexpired receipts are never pruned, and the separate +supervisor and Host-generation high-water marks survive terminal runs, pruning, +and restart. Legacy migrated payloads remain deliberately unsigned and provide no Host +authentication. New schema-27 receipts are signed atomically. The import-inert +wire-v3 Host consumes a fresh one-use challenge and strict V2 public keyring, +then verifies the signed envelope against the exact actor-issued attachment +descriptor before admitting one fenced turn. It is not referenced by boot or +existing Pi routing. Separate Host and gateway service identities, peer +credentials, keyring provisioning, and detached service deployment must land +before production wiring. The current shared Ubuntu identity is not a security +boundary. + +The hardened detached-host target keeps provider and MCP access gateway-proxied; +ambiguous proxy outcomes are visible `indeterminate` failures rather than +silent retries. Host workers use blue/green replacement with a 24-hour maximum +worker lifetime. Kernel and Host services run as separate service users. Host +ledger admission has no fixed concurrent-turn count. A turn may accumulate at +most 32 MiB of actual worst-case physical charge. Ordinary growth stops at +448 MiB and a protected 64 MiB remains inside, not beyond, the same 512 MiB +physical ceiling for emergency-class transitions. The import-inert encrypted +ledger v1 and conservative page/WAL accounting prototype are present but remain +production-unwired. Bun SQLite cannot expose exact dirty-page/checkpoint-peak +attribution, so production composition remains blocked on calibration and +ENOSPC/checkpoint proof. Signed challenge leases are required before use, and +same-UID processes are explicitly not treated as a security boundary. + ## Run ownership Run state is durable and explicit. Run events are typed actor messages. The diff --git a/opensession-agent-host@.service b/opensession-agent-host@.service new file mode 100644 index 0000000000..13878272f9 --- /dev/null +++ b/opensession-agent-host@.service @@ -0,0 +1,50 @@ +# Production-unactivated Agent Host generation service template. +# Placeholder values are rendered by deploy/install-agent-host-topology.sh. +[Unit] +Description=Open Session Agent Host generation %i +Requires=opensession-agent-host@%i.socket +After=opensession-agent-host@%i.socket +StartLimitIntervalSec=86400 +StartLimitBurst=2 + +[Service] +Type=simple +User=opensession-agent-host +Group=opensession-agent-host +WorkingDirectory=@@WORKING_DIRECTORY@@ +StateDirectory=opensession/agent-host/%i +StateDirectoryMode=0700 +LoadCredential=agent-host-ledger-keyring:/etc/opensession/credentials/agent-host/%i/ledger-keyring.json +LoadCredential=agent-host-supervision-keyring:/etc/opensession/credentials/agent-host/%i/supervision-keyring.json +ExecStartPre=@@BUN@@ run packages/core/opensession-server/src/agent-host/main.ts --doctor --generation %i --expected-gateway-uid @@GATEWAY_UID@@ --expected-host-uid @@HOST_UID@@ +ExecStart=@@BUN@@ run packages/core/opensession-server/src/agent-host/main.ts --generation %i --expected-gateway-uid @@GATEWAY_UID@@ --expected-host-uid @@HOST_UID@@ +RuntimeMaxSec=24h +TimeoutStopSec=20s +KillMode=mixed +Restart=no +UMask=0077 +NoNewPrivileges=true +CapabilityBoundingSet= +AmbientCapabilities= +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=read-only +ProtectControlGroups=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectKernelLogs=true +ProtectClock=true +ProtectHostname=true +ProtectProc=invisible +ProcSubset=pid +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +RemoveIPC=true +IPAddressDeny=any +SystemCallArchitectures=native + +# Deliberately no [Install]. Generations are activated only by an explicitly +# enabled socket after the future gateway cutover is approved. diff --git a/opensession-agent-host@.socket b/opensession-agent-host@.socket new file mode 100644 index 0000000000..6174d71ca7 --- /dev/null +++ b/opensession-agent-host@.socket @@ -0,0 +1,19 @@ +# Production-unactivated Agent Host generation socket template. +# Installed disabled by deploy/install-agent-host-topology.sh. +[Unit] +Description=Open Session Agent Host generation %i socket +PartOf=opensession-agent-host@%i.service + +[Socket] +ListenStream=/run/opensession/agent-host-%i.sock +FileDescriptorName=agent-host +Service=opensession-agent-host@%i.service +Accept=no +SocketUser=root +SocketGroup=opensession-gateway +SocketMode=0660 +DirectoryMode=0755 +RemoveOnStop=true + +[Install] +WantedBy=sockets.target diff --git a/packages/core/opensession-server/src/agent-host/canary-production-probes.test.ts b/packages/core/opensession-server/src/agent-host/canary-production-probes.test.ts new file mode 100644 index 0000000000..d195a0461b --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/canary-production-probes.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; +import { qualifyDetachedAgentHostCanary, type CanaryQualificationScenario } from "./canary-qualification"; +import { createCanaryProductionProbes, type CanaryProductionProbeOwners } from "./canary-production-probes"; + +const ID = "operation-1"; +const GENERATION = "generation-2"; +const FENCE = `sha256:${"a".repeat(64)}`; +const RECEIPT = `sha256:${"b".repeat(64)}`; +const durableCount = () => ({ + operationId: ID, fenceDigest: FENCE, receiptDigest: RECEIPT, durable: true as const, count: 1, +}); +type Mutable = { -readonly [K in keyof T]: T[K] }; +type TestOwners = Mutable & { + gateway: Mutable; + host: Mutable; + process: Mutable; + accounting: Mutable; +}; + +function fixture(): TestOwners { + return { + deadlineMs: 50, + gateway: { + submit: () => ({ operationId: ID }), + readEvidence: () => ({ + logicalOperationCount: 1, + terminalResults: [{ operationId: ID, status: "completed", visible: true }], + ackReplay: [{ ackSeq: 1, replaySeq: 1 }], + }), + }, + host: { + awaitPhysicalStart: () => {}, + readEvidence: () => ({ + operationId: ID, dispatchCount: 1, physicalRetryCount: 0, + executionGeneration: GENERATION, activeGeneration: GENERATION, + infrastructureFallback: false, executionPaths: ["agent-host"], + scenario: "normal", fenceDigest: FENCE, + }), + }, + process: { + applyScenario: (scenario: CanaryQualificationScenario) => ({ + scenario, certainty: "settled", + events: scenario === "blue-green-drain" + ? ["generation-draining", "generation-activated", "generation-drained"] + : [], + }), + }, + receipts: { + read: () => (["transcript", "operation", "kernel"] as const).map((kind) => ({ + kind, operationId: ID, terminal: "completed" as const, + })), + readDurableBinding: () => ({ + operationId: ID, fenceDigest: FENCE, receiptDigest: RECEIPT, terminalDurable: true, + }), + }, + transcript: { + read: () => ({ terminalEntries: [{ operationId: ID, status: "completed", visible: true }] }), + }, + accounting: { + model: { domainId: "billing:model", readDurableCount: durableCount }, + mcp: { domainId: "audit:mcp", readDurableCount: durableCount }, + executor: { domainId: "executor:physical", readDurableCount: durableCount }, + }, + }; +} + +describe("production canary probes", () => { + test("qualifies independent organization-controlled physical counters", async () => { + const result = await qualifyDetachedAgentHostCanary("normal", createCanaryProductionProbes(fixture())); + expect(result).toEqual({ + version: 1, scenario: "normal", outcome: "qualified", codes: ["QUALIFIED"], redacted: true, + }); + }); + + test("fails closed when counter domains collide by identity or domain id", async () => { + const identityCollision = fixture(); + identityCollision.accounting.mcp = identityCollision.accounting.model; + expect((await qualifyDetachedAgentHostCanary( + "normal", createCanaryProductionProbes(identityCollision), + )).codes).toEqual(["PROBE_FAILED"]); + + const idCollision = fixture(); + idCollision.accounting.executor = { domainId: "billing:model", readDurableCount: durableCount }; + expect((await qualifyDetachedAgentHostCanary( + "normal", createCanaryProductionProbes(idCollision), + )).codes).toEqual(["PROBE_FAILED"]); + }); + + test("detects fallback and direct execution evidence", async () => { + const owners = fixture(); + owners.host.readEvidence = () => ({ + operationId: ID, dispatchCount: 1, physicalRetryCount: 0, + executionGeneration: GENERATION, activeGeneration: GENERATION, + infrastructureFallback: true, executionPaths: ["agent-host", "direct"], + scenario: "normal", fenceDigest: FENCE, + }); + expect((await qualifyDetachedAgentHostCanary( + "normal", createCanaryProductionProbes(owners), + )).codes).toEqual(["INFRASTRUCTURE_FALLBACK_OBSERVED"]); + }); + + test("binds durable counters to operation, fence, and receipt digests", async () => { + const owners = fixture(); + owners.accounting.model.readDurableCount = () => ({ + ...durableCount(), receiptDigest: `sha256:${"c".repeat(64)}`, + }); + expect((await qualifyDetachedAgentHostCanary( + "normal", createCanaryProductionProbes(owners), + )).codes).toEqual(["PROBE_FAILED"]); + }); + + test("proves old turns drain after durable terminal while new admissions use active generation", async () => { + const owners = fixture(); + owners.host.readEvidence = () => ({ + operationId: ID, dispatchCount: 1, physicalRetryCount: 0, + executionGeneration: "generation-old", activeGeneration: "generation-new", + infrastructureFallback: false, executionPaths: ["agent-host"], + scenario: "blue-green-drain", fenceDigest: FENCE, + generationTransition: { + drainingGeneration: "generation-old", admissionGeneration: "generation-new", + oldTerminalDurable: true, oldDrainedAfterTerminal: true, + }, + }); + expect((await qualifyDetachedAgentHostCanary( + "blue-green-drain", createCanaryProductionProbes(owners), + )).codes).toEqual(["QUALIFIED"]); + }); + + test("bounds owners, aborts the signal, and redacts failures", async () => { + const owners = fixture(); + owners.deadlineMs = 2; + let aborted = false; + owners.gateway.submit = (_request, signal) => new Promise<{ readonly operationId: string }>((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted = true; + reject(new Error("provider payload token /private/config")); + }, { once: true }); + }); + const result = await qualifyDetachedAgentHostCanary("normal", createCanaryProductionProbes(owners)); + expect(result.codes).toEqual(["DEADLINE_EXCEEDED"]); + expect(aborted).toBe(true); + expect(JSON.stringify(result)).not.toContain("provider"); + expect(JSON.stringify(result)).not.toContain("/private"); + }); + + test("construction is inert until qualification invokes owners", async () => { + let calls = 0; + const owners = fixture(); + owners.gateway.submit = () => { calls++; throw new Error("private token"); }; + const probes = createCanaryProductionProbes(owners); + expect(calls).toBe(0); + const result = await qualifyDetachedAgentHostCanary("normal", probes); + expect(calls).toBe(1); + expect(result.codes).toEqual(["PROBE_FAILED"]); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/canary-production-probes.ts b/packages/core/opensession-server/src/agent-host/canary-production-probes.ts new file mode 100644 index 0000000000..a9fedc1dde --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/canary-production-probes.ts @@ -0,0 +1,228 @@ +import type { + CanaryDeadlineProbe, + CanaryGatewayEvidence, + CanaryHostEvidence, + CanaryInterventionEvidence, + CanaryOperationRequest, + CanaryQualificationProbes, + CanaryQualificationScenario, + CanaryQualificationStep, + CanaryReceiptEvidence, + CanaryTranscriptEvidence, +} from "./canary-qualification"; + +type MaybePromise = T | Promise; +const DIGEST = /^sha256:[a-f0-9]{64}$/; + +export interface CanaryProductionGatewayOwner { + submit(request: CanaryOperationRequest, signal: AbortSignal): MaybePromise<{ readonly operationId: string }>; + readEvidence(operationId: string, signal: AbortSignal): MaybePromise; +} + +export interface CanaryGenerationTransitionEvidence { + readonly drainingGeneration: string; + readonly admissionGeneration: string; + readonly oldTerminalDurable: true; + readonly oldDrainedAfterTerminal: true; +} + +export interface CanaryProductionHostEvidence extends Omit { + readonly scenario: CanaryQualificationScenario; + readonly fenceDigest: string; + /** Required only for blue-green-drain. */ + readonly generationTransition?: CanaryGenerationTransitionEvidence; +} + +export interface CanaryProductionHostOwner { + awaitPhysicalStart(operationId: string, signal: AbortSignal): MaybePromise; + readEvidence(operationId: string, signal: AbortSignal): MaybePromise; +} + +export interface CanaryProductionProcessOwner { + applyScenario(scenario: CanaryQualificationScenario, operationId: string, signal: AbortSignal): MaybePromise; +} +export interface CanaryDurableOperationBinding { + readonly operationId: string; + readonly fenceDigest: string; + readonly receiptDigest: string; + readonly terminalDurable: true; +} +export interface CanaryProductionReceiptOwner { + read(operationId: string, signal: AbortSignal): MaybePromise; + readDurableBinding(operationId: string, signal: AbortSignal): MaybePromise; +} +export interface CanaryProductionTranscriptOwner { + read(operationId: string, signal: AbortSignal): MaybePromise; +} + +export interface CanaryDurablePhysicalCount { + readonly operationId: string; + readonly fenceDigest: string; + readonly receiptDigest: string; + readonly durable: true; + readonly count: number; +} + +export interface CanaryPhysicalAccountingDomain { + /** Stable organization-controlled domain identity. Never included in reports. */ + readonly domainId: string; + readDurableCount(operationId: string, signal: AbortSignal): MaybePromise; +} + +export interface CanaryProductionProbeOwners { + readonly gateway: CanaryProductionGatewayOwner; + readonly host: CanaryProductionHostOwner; + readonly process: CanaryProductionProcessOwner; + readonly receipts: CanaryProductionReceiptOwner; + readonly transcript: CanaryProductionTranscriptOwner; + readonly accounting: Readonly<{ + model: CanaryPhysicalAccountingDomain; + mcp: CanaryPhysicalAccountingDomain; + executor: CanaryPhysicalAccountingDomain; + }>; + /** Per-step deadline, clamped to 1..30 seconds. */ + readonly deadlineMs?: number; +} + +const MAX_DEADLINE_MS = 30_000; +const DEFAULT_DEADLINE_MS = 5_000; +function boundedDeadline(value: number | undefined): number { + if (!Number.isFinite(value)) return DEFAULT_DEADLINE_MS; + return Math.min(MAX_DEADLINE_MS, Math.max(1, Math.trunc(value!))); +} +function validDomainId(value: string): boolean { + return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value); +} +function independentAccounting(accounting: CanaryProductionProbeOwners["accounting"]): boolean { + const domains = [accounting.model, accounting.mcp, accounting.executor]; + return new Set(domains).size === domains.length && domains.every((domain) => validDomainId(domain.domainId)) && + new Set(domains.map((domain) => domain.domainId)).size === domains.length; +} +function validCount( + proof: CanaryDurablePhysicalCount, + operationId: string, + fenceDigest: string, + receiptDigest: string, +): boolean { + return proof?.durable === true && proof.operationId === operationId && + proof.fenceDigest === fenceDigest && proof.receiptDigest === receiptDigest && + Number.isSafeInteger(proof.count) && proof.count >= 0; +} +function verifyGeneration( + scenario: CanaryQualificationScenario, + host: CanaryProductionHostEvidence, +): string { + if (scenario !== "blue-green-drain") { + if (host.generationTransition !== undefined || host.executionGeneration !== host.activeGeneration) + throw new Error("Generation evidence mismatch"); + return host.activeGeneration; + } + const transition = host.generationTransition; + if (!transition || transition.drainingGeneration !== host.executionGeneration || + transition.admissionGeneration !== host.activeGeneration || + transition.admissionGeneration === transition.drainingGeneration || + transition.oldTerminalDurable !== true || transition.oldDrainedAfterTerminal !== true) + throw new Error("Blue-green generation evidence mismatch"); + // The generic harness compares these fields. After proving the transition, + // project the old turn's pinned generation as its own qualification fence. + return host.executionGeneration; +} + +interface StepContext { readonly signal: AbortSignal } + +/** Import-inert adapter over organization-controlled production evidence owners. */ +export function createCanaryProductionProbes( + owners: Readonly, +): CanaryQualificationProbes { + const contexts = new Map(); + let submitted: { readonly operationId: string; readonly scenario: CanaryQualificationScenario } | undefined; + const context = (step: CanaryQualificationStep): StepContext => { + const value = contexts.get(step); + if (!value) throw new Error("Canary production probe called outside its bounded step"); + return value; + }; + const deadline: CanaryDeadlineProbe = { + async within(step: CanaryQualificationStep, task: () => Promise) { + if (contexts.has(step)) throw new Error("Concurrent canary qualification step"); + const controller = new AbortController(); + contexts.set(step, Object.freeze({ signal: controller.signal })); + let timer: ReturnType | undefined; + try { + const expired = new Promise<{ readonly status: "deadline" }>((resolve) => { + timer = setTimeout(() => { controller.abort(); resolve({ status: "deadline" }); }, boundedDeadline(owners.deadlineMs)); + }); + return await Promise.race([ + Promise.resolve().then(task).then((value) => ({ status: "completed" as const, value })), + expired, + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + contexts.delete(step); + } + }, + }; + + const probes: CanaryQualificationProbes = { + deadline, + gateway: { + async submit(request: CanaryOperationRequest) { + if (submitted) throw new Error("A canary qualification is already active"); + const result = await owners.gateway.submit(request, context("submit").signal); + if (result.operationId) submitted = Object.freeze({ operationId: result.operationId, scenario: request.scenario }); + return result; + }, + async readEvidence(operationId: string) { + return owners.gateway.readEvidence(operationId, context("visible-terminal").signal); + }, + }, + host: { + async awaitPhysicalStart(operationId: string) { + await owners.host.awaitPhysicalStart(operationId, context("physical-start").signal); + }, + async readEvidence(operationId: string): Promise { + const signal = context("host-evidence").signal; + const expected = submitted; + submitted = undefined; + if (!expected || expected.operationId !== operationId || !independentAccounting(owners.accounting)) + throw new Error("Missing qualification or independent physical accounting domains"); + const [host, binding, model, mcp, executor] = await Promise.all([ + owners.host.readEvidence(operationId, signal), + owners.receipts.readDurableBinding(operationId, signal), + owners.accounting.model.readDurableCount(operationId, signal), + owners.accounting.mcp.readDurableCount(operationId, signal), + owners.accounting.executor.readDurableCount(operationId, signal), + ]); + if (binding?.terminalDurable !== true || binding.operationId !== operationId || + !DIGEST.test(binding.fenceDigest) || !DIGEST.test(binding.receiptDigest) || + host.operationId !== operationId || host.fenceDigest !== binding.fenceDigest || + ![model, mcp, executor].every((proof) => + validCount(proof, operationId, binding.fenceDigest, binding.receiptDigest))) + throw new Error("Physical accounting proof is not durable or bound to the operation"); + if (host.scenario !== expected.scenario) throw new Error("Qualification scenario evidence mismatch"); + const projectedGeneration = verifyGeneration(expected.scenario, host); + return Object.freeze({ + operationId: host.operationId, + dispatchCount: host.dispatchCount, + physicalRetryCount: host.physicalRetryCount, + executionGeneration: host.executionGeneration, + activeGeneration: projectedGeneration, + infrastructureFallback: host.infrastructureFallback, + executionPaths: host.executionPaths, + physicalEffects: Object.freeze([ + Object.freeze({ kind: "model", counterId: "organization:model", count: model.count }), + Object.freeze({ kind: "mcp", counterId: "organization:mcp", count: mcp.count }), + Object.freeze({ kind: "executor", counterId: "organization:executor", count: executor.count }), + ]), + }); + }, + }, + process: { + async applyScenario(scenario: CanaryQualificationScenario, operationId: string) { + return owners.process.applyScenario(scenario, operationId, context("intervention").signal); + }, + }, + receipts: { async read(operationId: string) { return owners.receipts.read(operationId, context("receipts").signal); } }, + transcript: { async read(operationId: string) { return owners.transcript.read(operationId, context("transcript").signal); } }, + }; + return Object.freeze(probes); +} diff --git a/packages/core/opensession-server/src/agent-host/canary-qualification.test.ts b/packages/core/opensession-server/src/agent-host/canary-qualification.test.ts new file mode 100644 index 0000000000..8edf2fe769 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/canary-qualification.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, test } from "bun:test"; +import { + CANARY_QUALIFICATION_SCENARIOS, + qualifyDetachedAgentHostCanary, + type CanaryDeadlineProbe, + type CanaryGatewayEvidence, + type CanaryHostEvidence, + type CanaryInterventionEvidence, + type CanaryProcessEvent, + type CanaryQualificationProbes, + type CanaryQualificationScenario, + type CanaryQualificationStep, + type CanaryReceiptEvidence, + type CanaryTranscriptEvidence, +} from "./canary-qualification"; + +const OPERATION_ID = "canary-operation"; +const GENERATION = "agent-host:g2"; + +const EVENTS: Record = { + normal: [], + "gateway-sigkill-restart": ["gateway-sigkill", "gateway-restarted"], + "host-sigkill-restart": ["host-sigkill", "host-restarted"], + disconnect: ["transport-disconnected", "transport-reconnected"], + cancellation: ["cancellation-requested", "cancellation-acknowledged"], + "key-rotation": ["key-rotated"], + "blue-green-drain": [ + "generation-draining", + "generation-activated", + "generation-drained", + ], +}; + +function expectedTerminal(scenario: CanaryQualificationScenario) { + return scenario === "cancellation" ? "cancelled" : "completed"; +} + +function fixture(scenario: CanaryQualificationScenario = "normal") { + const terminal = expectedTerminal(scenario); + const data: { + gateway: CanaryGatewayEvidence; + host: CanaryHostEvidence; + intervention: CanaryInterventionEvidence; + receipts: CanaryReceiptEvidence[]; + transcript: CanaryTranscriptEvidence; + deadlineStep?: CanaryQualificationStep; + rejectStep?: CanaryQualificationStep; + } = { + gateway: { + logicalOperationCount: 1, + terminalResults: [ + { operationId: OPERATION_ID, status: terminal, visible: true }, + ], + ackReplay: [ + { ackSeq: 1, replaySeq: 0 }, + { ackSeq: 3, replaySeq: 2 }, + { ackSeq: 3, replaySeq: 3 }, + ], + }, + host: { + operationId: OPERATION_ID, + dispatchCount: 1, + physicalRetryCount: 0, + physicalEffects: [ + { kind: "model", counterId: "provider-counter", count: 1 }, + { kind: "mcp", counterId: "mcp-counter", count: 1 }, + { kind: "executor", counterId: "executor-counter", count: 1 }, + ], + executionGeneration: GENERATION, + activeGeneration: GENERATION, + infrastructureFallback: false, + executionPaths: ["agent-host"], + }, + intervention: { + scenario, + certainty: "settled", + events: [...EVENTS[scenario]], + }, + receipts: (["transcript", "operation", "kernel"] as const).map((kind) => ({ + kind, + operationId: OPERATION_ID, + terminal, + })), + transcript: { + terminalEntries: [ + { operationId: OPERATION_ID, status: terminal, visible: true }, + ], + }, + }; + const calls = { + submit: 0, + physicalStart: 0, + intervention: 0, + gatewayEvidence: 0, + hostEvidence: 0, + receipts: 0, + transcript: 0, + }; + const maybeReject = (step: CanaryQualificationStep) => { + if (data.rejectStep === step) throw new Error("private probe detail"); + }; + const deadline: CanaryDeadlineProbe = { + async within(step, task) { + if (data.deadlineStep === step) return { status: "deadline" }; + return { status: "completed", value: await task() }; + }, + }; + const probes: CanaryQualificationProbes = { + deadline, + gateway: { + async submit(request) { + calls.submit++; + maybeReject("submit"); + expect(request).toEqual({ + scenario, + effectKinds: ["model", "mcp", "executor"], + executionPath: "agent-host", + allowInfrastructureFallback: false, + physicalRetryLimit: 0, + }); + return { operationId: OPERATION_ID }; + }, + async readEvidence() { + calls.gatewayEvidence++; + maybeReject("visible-terminal"); + return data.gateway; + }, + }, + host: { + async awaitPhysicalStart() { + calls.physicalStart++; + maybeReject("physical-start"); + }, + async readEvidence() { + calls.hostEvidence++; + maybeReject("host-evidence"); + return data.host; + }, + }, + process: { + async applyScenario() { + calls.intervention++; + maybeReject("intervention"); + return data.intervention; + }, + }, + receipts: { + async read() { + calls.receipts++; + maybeReject("receipts"); + return data.receipts; + }, + }, + transcript: { + async read() { + calls.transcript++; + maybeReject("transcript"); + return data.transcript; + }, + }, + }; + return { data, calls, probes }; +} + +async function codeFor( + mutate: (value: ReturnType["data"]) => void, + scenario: CanaryQualificationScenario = "normal", +) { + const value = fixture(scenario); + mutate(value.data); + const result = await qualifyDetachedAgentHostCanary(scenario, value.probes); + expect(result.outcome).toBe("failed"); + expect(result.redacted).toBe(true); + return result.codes[0]; +} + +describe("detached Agent Host canary qualification", () => { + for (const scenario of CANARY_QUALIFICATION_SCENARIOS) { + test(`qualifies ${scenario}`, async () => { + const { calls, probes } = fixture(scenario); + const result = await qualifyDetachedAgentHostCanary(scenario, probes); + expect(result).toEqual({ + version: 1, + scenario, + outcome: "qualified", + codes: ["QUALIFIED"], + redacted: true, + }); + expect(calls).toEqual({ + submit: 1, + physicalStart: 1, + intervention: 1, + gatewayEvidence: 1, + hostEvidence: 1, + receipts: 1, + transcript: 1, + }); + }); + } + + test("fails a scenario whose injected process evidence is incomplete", async () => { + expect( + await codeFor((data) => { + data.intervention = { + ...data.intervention, + events: ["gateway-sigkill"], + }; + }, "gateway-sigkill-restart"), + ).toBe("SCENARIO_MISMATCH"); + }); + + test("fails missing or duplicate logical operations", async () => { + for (const count of [0, 2]) { + expect( + await codeFor((data) => { + data.gateway = { ...data.gateway, logicalOperationCount: count }; + }), + ).toBe("LOGICAL_COUNT_MISMATCH"); + } + }); + + test("fails missing, duplicate, or non-independent physical effects", async () => { + const mutations = [ + (data: ReturnType["data"]) => { + data.host = { + ...data.host, + physicalEffects: data.host.physicalEffects.slice(1), + }; + }, + (data: ReturnType["data"]) => { + data.host = { + ...data.host, + physicalEffects: [ + ...data.host.physicalEffects, + data.host.physicalEffects[0]!, + ], + }; + }, + (data: ReturnType["data"]) => { + data.host = { + ...data.host, + physicalEffects: data.host.physicalEffects.map((effect) => ({ + ...effect, + counterId: "shared-counter", + })), + }; + }, + ]; + for (const mutation of mutations) + expect(await codeFor(mutation)).toBe("PHYSICAL_COUNT_MISMATCH"); + }); + + test("fails duplicate dispatch and any physical retry", async () => { + expect( + await codeFor((data) => { + data.host = { ...data.host, dispatchCount: 2 }; + }), + ).toBe("PHYSICAL_COUNT_MISMATCH"); + expect( + await codeFor((data) => { + data.host = { ...data.host, physicalRetryCount: 1 }; + }), + ).toBe("PHYSICAL_RETRY_OBSERVED"); + }); + + test("fails missing, duplicate, hidden, or wrong terminal results", async () => { + const variants: CanaryGatewayEvidence["terminalResults"][] = [ + [], + [ + { operationId: OPERATION_ID, status: "completed", visible: true }, + { operationId: OPERATION_ID, status: "completed", visible: true }, + ], + [{ operationId: OPERATION_ID, status: "completed", visible: false }], + [{ operationId: "other", status: "completed", visible: true }], + ]; + for (const terminalResults of variants) + expect( + await codeFor((data) => { + data.gateway = { ...data.gateway, terminalResults }; + }), + ).toBe("TERMINAL_RESULT_MISMATCH"); + }); + + test("fails missing, duplicate, or mismatched exact receipts", async () => { + const base = fixture().data.receipts; + const variants: CanaryReceiptEvidence[][] = [ + base.slice(1), + [...base, base[0]!], + base.map((receipt, index) => + index === 0 ? { ...receipt, operationId: "other" } : receipt, + ), + ]; + for (const receipts of variants) + expect( + await codeFor((data) => { + data.receipts = receipts; + }), + ).toBe("RECEIPT_MISMATCH"); + }); + + test("fails missing, duplicate, or mismatched transcript terminals", async () => { + const entry = fixture().data.transcript.terminalEntries[0]!; + for (const terminalEntries of [ + [], + [entry, entry], + [{ ...entry, visible: false }], + ]) + expect( + await codeFor((data) => { + data.transcript = { terminalEntries }; + }), + ).toBe("TRANSCRIPT_MISMATCH"); + }); + + test("fails absent, regressing, invalid, or ahead-of-ACK replay evidence", async () => { + const variants = [ + [], + [ + { ackSeq: 2, replaySeq: 1 }, + { ackSeq: 1, replaySeq: 1 }, + ], + [ + { ackSeq: 2, replaySeq: 2 }, + { ackSeq: 3, replaySeq: 1 }, + ], + [{ ackSeq: 1, replaySeq: 2 }], + [{ ackSeq: -1, replaySeq: 0 }], + ]; + for (const ackReplay of variants) + expect( + await codeFor((data) => { + data.gateway = { ...data.gateway, ackReplay }; + }), + ).toBe("ACK_REPLAY_MISMATCH"); + }); + + test("fails stale or missing active generation evidence", async () => { + for (const activeGeneration of ["agent-host:g3", ""]) + expect( + await codeFor((data) => { + data.host = { ...data.host, activeGeneration }; + }), + ).toBe("GENERATION_MISMATCH"); + }); + + test("fails infrastructure fallback and runner-host or direct paths", async () => { + expect( + await codeFor((data) => { + data.host = { ...data.host, infrastructureFallback: true }; + }), + ).toBe("INFRASTRUCTURE_FALLBACK_OBSERVED"); + for (const executionPaths of [ + ["runner-host"], + ["direct"], + ["agent-host", "direct"], + ]) + expect( + await codeFor((data) => { + data.host = { ...data.host, executionPaths }; + }), + ).toBe("FORBIDDEN_EXECUTION_PATH"); + }); + + test("rejects ambiguity outside a kill scenario", async () => { + expect( + await codeFor((data) => { + data.intervention = { ...data.intervention, certainty: "ambiguous" }; + }), + ).toBe("SCENARIO_MISMATCH"); + }); + + for (const scenario of [ + "gateway-sigkill-restart", + "host-sigkill-restart", + ] as const) { + test(`returns indeterminate without retry after ambiguous ${scenario}`, async () => { + const { data, calls, probes } = fixture(scenario); + data.intervention = { ...data.intervention, certainty: "ambiguous" }; + const result = await qualifyDetachedAgentHostCanary(scenario, probes); + expect(result).toEqual({ + version: 1, + scenario, + outcome: "indeterminate", + codes: ["AMBIGUOUS_EFFECT"], + redacted: true, + }); + expect(calls.submit).toBe(1); + expect(calls.physicalStart).toBe(1); + expect(calls.intervention).toBe(1); + expect(calls.gatewayEvidence).toBe(0); + expect(calls.hostEvidence).toBe(0); + }); + } + + test("fails closed on every bounded deadline", async () => { + for (const deadlineStep of [ + "submit", + "physical-start", + "intervention", + "visible-terminal", + "host-evidence", + "receipts", + "transcript", + ] as const) { + const { data, probes } = fixture(); + data.deadlineStep = deadlineStep; + const result = await qualifyDetachedAgentHostCanary("normal", probes); + expect(result.codes).toEqual(["DEADLINE_EXCEEDED"]); + expect(result.outcome).toBe("failed"); + } + }); + + test("redacts arbitrary probe failures into fixed vocabulary", async () => { + const { data, probes } = fixture(); + data.rejectStep = "receipts"; + const result = await qualifyDetachedAgentHostCanary("normal", probes); + expect(result).toEqual({ + version: 1, + scenario: "normal", + outcome: "failed", + codes: ["PROBE_FAILED"], + redacted: true, + }); + expect(JSON.stringify(result)).not.toContain("private probe detail"); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/canary-qualification.ts b/packages/core/opensession-server/src/agent-host/canary-qualification.ts new file mode 100644 index 0000000000..ed71e7d468 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/canary-qualification.ts @@ -0,0 +1,405 @@ +export const CANARY_QUALIFICATION_SCENARIOS = [ + "normal", + "gateway-sigkill-restart", + "host-sigkill-restart", + "disconnect", + "cancellation", + "key-rotation", + "blue-green-drain", +] as const; + +export type CanaryQualificationScenario = + (typeof CANARY_QUALIFICATION_SCENARIOS)[number]; + +export type CanaryQualificationStep = + | "submit" + | "physical-start" + | "intervention" + | "visible-terminal" + | "host-evidence" + | "receipts" + | "transcript"; + +export type CanaryQualificationCode = + | "QUALIFIED" + | "AMBIGUOUS_EFFECT" + | "DEADLINE_EXCEEDED" + | "PROBE_FAILED" + | "SCENARIO_MISMATCH" + | "LOGICAL_COUNT_MISMATCH" + | "PHYSICAL_COUNT_MISMATCH" + | "PHYSICAL_RETRY_OBSERVED" + | "TERMINAL_RESULT_MISMATCH" + | "RECEIPT_MISMATCH" + | "TRANSCRIPT_MISMATCH" + | "ACK_REPLAY_MISMATCH" + | "GENERATION_MISMATCH" + | "INFRASTRUCTURE_FALLBACK_OBSERVED" + | "FORBIDDEN_EXECUTION_PATH"; + +export interface CanaryOperationRequest { + readonly scenario: CanaryQualificationScenario; + readonly effectKinds: readonly ["model", "mcp", "executor"]; + readonly executionPath: "agent-host"; + readonly allowInfrastructureFallback: false; + readonly physicalRetryLimit: 0; +} + +export interface CanaryTerminalResult { + readonly operationId: string; + readonly status: "completed" | "cancelled"; + readonly visible: boolean; +} + +export interface CanaryAckReplaySample { + readonly ackSeq: number; + readonly replaySeq: number; +} + +export interface CanaryGatewayEvidence { + readonly logicalOperationCount: number; + readonly terminalResults: readonly CanaryTerminalResult[]; + readonly ackReplay: readonly CanaryAckReplaySample[]; +} + +export interface CanaryPhysicalEffectEvidence { + readonly kind: "model" | "mcp" | "executor"; + /** Identifies an independently maintained counter, not an operation ID. */ + readonly counterId: string; + readonly count: number; +} + +export interface CanaryHostEvidence { + readonly operationId: string; + readonly dispatchCount: number; + readonly physicalRetryCount: number; + readonly physicalEffects: readonly CanaryPhysicalEffectEvidence[]; + readonly executionGeneration: string; + readonly activeGeneration: string; + readonly infrastructureFallback: boolean; + readonly executionPaths: readonly string[]; +} + +export type CanaryProcessEvent = + | "gateway-sigkill" + | "gateway-restarted" + | "host-sigkill" + | "host-restarted" + | "transport-disconnected" + | "transport-reconnected" + | "cancellation-requested" + | "cancellation-acknowledged" + | "key-rotated" + | "generation-draining" + | "generation-activated" + | "generation-drained"; + +export interface CanaryInterventionEvidence { + readonly scenario: CanaryQualificationScenario; + readonly certainty: "settled" | "ambiguous"; + readonly events: readonly CanaryProcessEvent[]; +} + +export interface CanaryReceiptEvidence { + readonly kind: "transcript" | "operation" | "kernel"; + readonly operationId: string; + readonly terminal: "completed" | "cancelled"; +} + +export interface CanaryTranscriptEvidence { + readonly terminalEntries: readonly CanaryTerminalResult[]; +} + +export interface CanaryGatewayProbe { + submit( + request: CanaryOperationRequest, + ): Promise<{ readonly operationId: string }>; + readEvidence(operationId: string): Promise; +} + +export interface CanaryHostProbe { + awaitPhysicalStart(operationId: string): Promise; + readEvidence(operationId: string): Promise; +} + +/** An implementation may simulate process loss. The harness never sends signals. */ +export interface CanaryProcessProbe { + applyScenario( + scenario: CanaryQualificationScenario, + operationId: string, + ): Promise; +} + +export interface CanaryReceiptProbe { + read(operationId: string): Promise; +} + +export interface CanaryTranscriptProbe { + read(operationId: string): Promise; +} + +export type CanaryDeadlineResult = + | { readonly status: "completed"; readonly value: T } + | { readonly status: "deadline" }; + +/** Bounds each probe independently. Implementations own clocks and timers. */ +export interface CanaryDeadlineProbe { + within( + step: CanaryQualificationStep, + task: () => Promise, + ): Promise>; +} + +export interface CanaryQualificationProbes { + readonly gateway: CanaryGatewayProbe; + readonly host: CanaryHostProbe; + readonly process: CanaryProcessProbe; + readonly receipts: CanaryReceiptProbe; + readonly transcript: CanaryTranscriptProbe; + readonly deadline: CanaryDeadlineProbe; +} + +export interface CanaryQualificationReport { + readonly version: 1; + readonly scenario: CanaryQualificationScenario; + readonly outcome: "qualified" | "failed" | "indeterminate"; + /** Fixed vocabulary only. Probe errors and identifiers are never copied here. */ + readonly codes: readonly CanaryQualificationCode[]; + readonly redacted: true; +} + +const EXPECTED_EVENTS = Object.freeze({ + normal: [], + "gateway-sigkill-restart": ["gateway-sigkill", "gateway-restarted"], + "host-sigkill-restart": ["host-sigkill", "host-restarted"], + disconnect: ["transport-disconnected", "transport-reconnected"], + cancellation: ["cancellation-requested", "cancellation-acknowledged"], + "key-rotation": ["key-rotated"], + "blue-green-drain": [ + "generation-draining", + "generation-activated", + "generation-drained", + ], +} as const satisfies Readonly< + Record +>); + +class QualificationFailure extends Error { + constructor(readonly code: CanaryQualificationCode) { + super(code); + } +} + +function fail(code: CanaryQualificationCode): never { + throw new QualificationFailure(code); +} + +function exactStrings( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function exactTerminalStatus( + scenario: CanaryQualificationScenario, +): "completed" | "cancelled" { + return scenario === "cancellation" ? "cancelled" : "completed"; +} + +async function bounded( + deadline: CanaryDeadlineProbe, + step: CanaryQualificationStep, + task: () => Promise, +): Promise { + const result = await deadline.within(step, task); + if (result.status === "deadline") fail("DEADLINE_EXCEEDED"); + return result.value; +} + +function validateGateway( + evidence: CanaryGatewayEvidence, + operationId: string, + terminal: "completed" | "cancelled", +): void { + if (evidence.logicalOperationCount !== 1) fail("LOGICAL_COUNT_MISMATCH"); + if ( + evidence.terminalResults.length !== 1 || + evidence.terminalResults[0]?.operationId !== operationId || + evidence.terminalResults[0]?.status !== terminal || + evidence.terminalResults[0]?.visible !== true + ) + fail("TERMINAL_RESULT_MISMATCH"); + + if (evidence.ackReplay.length === 0) fail("ACK_REPLAY_MISMATCH"); + let previousAck = -1; + let previousReplay = -1; + for (const sample of evidence.ackReplay) { + if ( + !Number.isSafeInteger(sample.ackSeq) || + !Number.isSafeInteger(sample.replaySeq) || + sample.ackSeq < 0 || + sample.replaySeq < 0 || + sample.ackSeq < previousAck || + sample.replaySeq < previousReplay || + sample.replaySeq > sample.ackSeq + ) + fail("ACK_REPLAY_MISMATCH"); + previousAck = sample.ackSeq; + previousReplay = sample.replaySeq; + } +} + +function validateHost(evidence: CanaryHostEvidence, operationId: string): void { + if (evidence.operationId !== operationId || evidence.dispatchCount !== 1) + fail("PHYSICAL_COUNT_MISMATCH"); + if (evidence.physicalRetryCount !== 0) fail("PHYSICAL_RETRY_OBSERVED"); + + const expectedKinds = ["model", "mcp", "executor"] as const; + if ( + evidence.physicalEffects.length !== expectedKinds.length || + !expectedKinds.every((kind) => { + const matches = evidence.physicalEffects.filter( + (effect) => effect.kind === kind, + ); + return matches.length === 1 && matches[0]?.count === 1; + }) || + new Set(evidence.physicalEffects.map((effect) => effect.counterId)).size !== + expectedKinds.length || + evidence.physicalEffects.some((effect) => effect.counterId.length === 0) + ) + fail("PHYSICAL_COUNT_MISMATCH"); + + if ( + evidence.activeGeneration.length === 0 || + evidence.executionGeneration !== evidence.activeGeneration + ) + fail("GENERATION_MISMATCH"); + if (evidence.infrastructureFallback !== false) + fail("INFRASTRUCTURE_FALLBACK_OBSERVED"); + if (!exactStrings(evidence.executionPaths, ["agent-host"])) + fail("FORBIDDEN_EXECUTION_PATH"); +} + +function validateReceipts( + receipts: readonly CanaryReceiptEvidence[], + operationId: string, + terminal: "completed" | "cancelled", +): void { + const kinds = ["transcript", "operation", "kernel"] as const; + if ( + receipts.length !== kinds.length || + !kinds.every((kind) => { + const matches = receipts.filter((receipt) => receipt.kind === kind); + return ( + matches.length === 1 && + matches[0]?.operationId === operationId && + matches[0]?.terminal === terminal + ); + }) + ) + fail("RECEIPT_MISMATCH"); +} + +function validateTranscript( + evidence: CanaryTranscriptEvidence, + operationId: string, + terminal: "completed" | "cancelled", +): void { + if ( + evidence.terminalEntries.length !== 1 || + evidence.terminalEntries[0]?.operationId !== operationId || + evidence.terminalEntries[0]?.status !== terminal || + evidence.terminalEntries[0]?.visible !== true + ) + fail("TRANSCRIPT_MISMATCH"); +} + +function report( + scenario: CanaryQualificationScenario, + outcome: CanaryQualificationReport["outcome"], + code: CanaryQualificationCode, +): CanaryQualificationReport { + return Object.freeze({ + version: 1, + scenario, + outcome, + codes: Object.freeze([code]), + redacted: true, + }); +} + +/** + * Runs one fail-closed qualification case. It submits exactly once and has no + * operation retry branch. All production interaction is supplied by probes. + */ +export async function qualifyDetachedAgentHostCanary( + scenario: CanaryQualificationScenario, + probes: CanaryQualificationProbes, +): Promise { + try { + const submitted = await bounded(probes.deadline, "submit", () => + probes.gateway.submit({ + scenario, + effectKinds: ["model", "mcp", "executor"], + executionPath: "agent-host", + allowInfrastructureFallback: false, + physicalRetryLimit: 0, + }), + ); + if (!submitted.operationId) fail("LOGICAL_COUNT_MISMATCH"); + const operationId = submitted.operationId; + + await bounded(probes.deadline, "physical-start", () => + probes.host.awaitPhysicalStart(operationId), + ); + const intervention = await bounded(probes.deadline, "intervention", () => + probes.process.applyScenario(scenario, operationId), + ); + if (intervention.certainty === "ambiguous") { + if ( + scenario !== "gateway-sigkill-restart" && + scenario !== "host-sigkill-restart" + ) + fail("SCENARIO_MISMATCH"); + return report(scenario, "indeterminate", "AMBIGUOUS_EFFECT"); + } + if ( + intervention.scenario !== scenario || + !exactStrings(intervention.events, EXPECTED_EVENTS[scenario]) + ) + fail("SCENARIO_MISMATCH"); + + const terminal = exactTerminalStatus(scenario); + const gateway = await bounded(probes.deadline, "visible-terminal", () => + probes.gateway.readEvidence(operationId), + ); + validateGateway(gateway, operationId, terminal); + + const host = await bounded(probes.deadline, "host-evidence", () => + probes.host.readEvidence(operationId), + ); + validateHost(host, operationId); + + const receipts = await bounded(probes.deadline, "receipts", () => + probes.receipts.read(operationId), + ); + validateReceipts(receipts, operationId, terminal); + + const transcript = await bounded(probes.deadline, "transcript", () => + probes.transcript.read(operationId), + ); + validateTranscript(transcript, operationId, terminal); + + return report(scenario, "qualified", "QUALIFIED"); + } catch (error) { + return report( + scenario, + "failed", + error instanceof QualificationFailure ? error.code : "PROBE_FAILED", + ); + } +} diff --git a/packages/core/opensession-server/src/agent-host/driver.test.ts b/packages/core/opensession-server/src/agent-host/driver.test.ts new file mode 100644 index 0000000000..2361bbed87 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/driver.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from "bun:test"; +import type { + AgentOperationDigest, + AgentTurnSpec, +} from "@tellahq/opensession-protocol"; +import type { + AgentHostOperationCancel, + AgentHostOperationQuery, + AgentHostOperationRequest, + AgentHostOperationStream, + AgentHostOperationTransport, + AgentTurnDriver, + AgentTurnResult, +} from "./driver"; + +const digest = (character: string) => + `sha256:${character.repeat(64)}` as AgentOperationDigest; + +const descriptor = { + version: 1 as const, + kind: "model" as const, + stepId: "step-1", + transcript: { + throughChangeSeq: 4, + entryIds: ["entry-1"], + digest: digest("a"), + }, + modelPolicyHash: digest("b"), + adapterRequestVersion: "model-request.v1", +}; + +const request: AgentHostOperationRequest = { + operationId: "operation-1", + descriptor, + descriptorDigest: digest("c"), + deadlineMs: 2_000, +}; +const query: AgentHostOperationQuery = { + operationId: "operation-1", + kind: "model", + descriptorDigest: digest("c"), + payloadDigest: digest("d"), + afterStreamSeq: 3, +}; +const cancel: AgentHostOperationCancel = { + operationId: "operation-1", + cancelId: "cancel-1", + reason: "user", +}; +const stream = (streamSeq: number): AgentHostOperationStream => ({ + operationId: "operation-1", + streamSeq, + encoding: "base64url+opensession-operation-v1", + bytes: "aGVsbG8", +}); + +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((ok, fail) => { + resolve = ok; + reject = fail; + }); + return { promise, resolve, reject }; +} + +class FakeTransport implements AgentHostOperationTransport { + readonly requests: AgentHostOperationRequest[] = []; + readonly queries: AgentHostOperationQuery[] = []; + readonly cancellations: AgentHostOperationCancel[] = []; + requestResult = Promise.resolve(); + queryResult = Promise.resolve(); + cancelResult = Promise.resolve(); + + requestOperation(value: AgentHostOperationRequest) { + this.requests.push(value); + return this.requestResult; + } + + queryOperation(value: AgentHostOperationQuery) { + this.queries.push(value); + return this.queryResult; + } + + cancelOperation(value: AgentHostOperationCancel) { + this.cancellations.push(value); + return this.cancelResult; + } +} + +class FakeDriver implements AgentTurnDriver { + readonly deliveries: number[] = []; + readonly gates: ReturnType[] = []; + + async run( + _spec: AgentTurnSpec, + _transport: AgentHostOperationTransport, + ): Promise { + return { status: "completed" }; + } + + deliverOperationStream(value: AgentHostOperationStream) { + this.deliveries.push(value.streamSeq); + const gate = deferred(); + this.gates.push(gate); + return gate.promise; + } + + async cancel() {} + async shutdown() {} +} + +describe("Agent Host driver v5 boundary", () => { + test("forwards exact request, query, and cancel operation intents", async () => { + const transport = new FakeTransport(); + + await transport.requestOperation(request); + await transport.queryOperation(query); + await transport.cancelOperation(cancel); + + expect(transport.requests).toEqual([request]); + expect(transport.queries).toEqual([query]); + expect(transport.cancellations).toEqual([cancel]); + }); + + test("propagates operation transport rejection without translation", async () => { + const transport = new FakeTransport(); + const requestError = new Error("request rejected"); + const queryError = new Error("query rejected"); + const cancelError = new Error("cancel rejected"); + transport.requestResult = Promise.reject(requestError); + transport.queryResult = Promise.reject(queryError); + transport.cancelResult = Promise.reject(cancelError); + + expect(transport.requestOperation(request)).rejects.toBe(requestError); + expect(transport.queryOperation(query)).rejects.toBe(queryError); + expect(transport.cancelOperation(cancel)).rejects.toBe(cancelError); + }); + + test("opaque stream delivery applies ordering and backpressure before ACK", async () => { + const driver = new FakeDriver(); + const acknowledgements: number[] = []; + let delivery = Promise.resolve(); + const deliverThenAck = (value: AgentHostOperationStream) => { + delivery = delivery.then(async () => { + await driver.deliverOperationStream(value); + acknowledgements.push(value.streamSeq); + }); + return delivery; + }; + + const first = deliverThenAck(stream(1)); + const second = deliverThenAck(stream(2)); + await Promise.resolve(); + expect(driver.deliveries).toEqual([1]); + expect(acknowledgements).toEqual([]); + + driver.gates[0]!.resolve(); + await first; + expect(acknowledgements).toEqual([1]); + expect(driver.deliveries).toEqual([1, 2]); + + driver.gates[1]!.resolve(); + await second; + expect(acknowledgements).toEqual([1, 2]); + }); + + test("exposes no policy-rich v4 inputs or control methods", () => { + const allowedKeys = new Set([ + "operationId", + "descriptor", + "descriptorDigest", + "deadlineMs", + "kind", + "payloadDigest", + "afterStreamSeq", + "cancelId", + "reason", + "streamSeq", + "encoding", + "bytes", + ]); + const forbidden = [ + "authority", + "prompt", + "images", + "model", + "provider", + "mcpPolicy", + "identity", + "credentials", + "url", + "headers", + "env", + "executorGrant", + "accessGrant", + ]; + + for (const value of [request, query, cancel, stream(1)]) { + expect(Object.keys(value).every((key) => allowedKeys.has(key))).toBe(true); + for (const key of forbidden) expect(key in value).toBe(false); + } + expect(Object.getOwnPropertyNames(FakeTransport.prototype).sort()).toEqual([ + "cancelOperation", + "constructor", + "queryOperation", + "requestOperation", + ]); + expect(Object.getOwnPropertyNames(FakeDriver.prototype).sort()).toEqual([ + "cancel", + "constructor", + "deliverOperationStream", + "run", + "shutdown", + ]); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/driver.ts b/packages/core/opensession-server/src/agent-host/driver.ts new file mode 100644 index 0000000000..86b3086d66 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/driver.ts @@ -0,0 +1,66 @@ +import type { + AgentHostInitialOperationV4, + AgentHostOperationCancelV4, + AgentHostOperationQueryV4, + AgentHostOperationStreamV4, + AgentTurnSpec, +} from "@tellahq/opensession-protocol"; + +export type AgentTurnResult = + { status: "completed" | "cancelled" } | { status: "failed"; error: string }; + +/** Descriptor-only operation intent. Authority and dispatch policy stay in the + * coordinator that implements the transport. */ +export type AgentHostOperationRequest = Readonly; + +export type AgentHostOperationQuery = Readonly< + Pick< + AgentHostOperationQueryV4, + | "operationId" + | "kind" + | "descriptorDigest" + | "payloadDigest" + | "afterStreamSeq" + > +>; + +export type AgentHostOperationCancel = Readonly< + Pick< + AgentHostOperationCancelV4, + "operationId" | "cancelId" | "reason" + > +>; + +/** Opaque operation bytes. The driver cannot recover provider or tool policy + * from this envelope. */ +export type AgentHostOperationStream = Readonly< + Pick< + AgentHostOperationStreamV4, + "operationId" | "streamSeq" | "encoding" | "bytes" + > +>; + +/** The only coordinator capability exposed to a model-loop driver. Each + * promise settles only when the coordinator has accepted or rejected the + * operation intent. */ +export interface AgentHostOperationTransport { + requestOperation(request: AgentHostOperationRequest): Promise; + queryOperation(query: AgentHostOperationQuery): Promise; + cancelOperation(cancel: AgentHostOperationCancel): Promise; +} + +/** One model-loop implementation for one descriptor-only turn. */ +export interface AgentTurnDriver { + run( + spec: AgentTurnSpec, + transport: AgentHostOperationTransport, + ): Promise; + + /** Resolve only after the opaque chunk has been consumed. The Host must not + * issue a cumulative stream ACK before this promise resolves. */ + deliverOperationStream(stream: AgentHostOperationStream): Promise; + cancel(): Promise; + shutdown(): Promise; +} + +export type AgentTurnDriverFactory = (spec: AgentTurnSpec) => AgentTurnDriver; diff --git a/packages/core/opensession-server/src/agent-host/emergency-reserve.test.ts b/packages/core/opensession-server/src/agent-host/emergency-reserve.test.ts new file mode 100644 index 0000000000..f6aee5c37d --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/emergency-reserve.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { + closeSync, + constants, + mkdtempSync, + openSync, + rmSync, + statSync, + writeSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LEDGER_PROTECTED_PHYSICAL_BYTES } from "./ledger-accounting"; +import { GenerationEmergencyReserve } from "./emergency-reserve"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); +}); + +function directory(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(dir); + return dir; +} + +describe("generation emergency reserve", () => { + test("physically preallocates 64 MiB with exact generation ownership and mode", () => { + const dir = directory("agent-host-reserve-"); + const reserve = new GenerationEmergencyReserve({ stateDirectory: dir }); + reserve.replenish(); + const stat = statSync(reserve.path); + expect(stat.size).toBe(LEDGER_PROTECTED_PHYSICAL_BYTES); + expect(Number(stat.blocks) * 512).toBeGreaterThanOrEqual(stat.size); + expect(stat.mode & 0o777).toBe(0o600); + expect(stat.uid).toBe(process.getuid!()); + expect(stat.gid).toBe(process.getgid!()); + const released = reserve.consume(12345); + expect(released).toBeGreaterThanOrEqual(12345); + reserve.replenish(); + expect(statSync(reserve.path).size).toBe(LEDGER_PROTECTED_PHYSICAL_BYTES); + reserve.close(); + }); + + test("keeps reserve blocks when another consumer reaches real ENOSPC where mounting is permitted", () => { + if (process.platform !== "linux" || process.getuid?.() !== 0) return; + const mountpoint = directory("agent-host-reserve-fs-"); + const mounted = spawnSync("mount", [ + "-t", + "tmpfs", + "-o", + "size=80m,mode=700", + "tmpfs", + mountpoint, + ]); + if (mounted.status !== 0) return; // CI commonly lacks CAP_SYS_ADMIN. + try { + const reserve = new GenerationEmergencyReserve({ + stateDirectory: mountpoint, + }); + reserve.replenish(); + const fd = openSync( + join(mountpoint, "unrelated-consumer"), + constants.O_CREAT | constants.O_WRONLY, + 0o600, + ); + const chunk = new Uint8Array(1024 * 1024); + let sawEnospc = false; + try { + for (;;) writeSync(fd, chunk); + } catch (error) { + sawEnospc = (error as NodeJS.ErrnoException).code === "ENOSPC"; + } finally { + closeSync(fd); + } + expect(sawEnospc).toBe(true); + const after = reserve.snapshot(); + expect(after.logicalBytes).toBe(LEDGER_PROTECTED_PHYSICAL_BYTES); + expect(after.allocatedBytes).toBeGreaterThanOrEqual(after.logicalBytes); + reserve.close(); + } finally { + spawnSync("umount", [mountpoint]); + } + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/emergency-reserve.ts b/packages/core/opensession-server/src/agent-host/emergency-reserve.ts new file mode 100644 index 0000000000..b1a62bc901 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/emergency-reserve.ts @@ -0,0 +1,175 @@ +import { + closeSync, + constants, + fchmodSync, + fchownSync, + fdatasyncSync, + fstatSync, + fsyncSync, + ftruncateSync, + lstatSync, + openSync, + statfsSync, + writeSync, +} from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { LEDGER_PROTECTED_PHYSICAL_BYTES } from "./ledger-accounting"; + +const ZERO_CHUNK = new Uint8Array(1024 * 1024); +const RESERVE_MODE = 0o600; + +export interface GenerationOwner { + readonly uid: number; + readonly gid: number; +} + +export interface EmergencyReserveOptions { + readonly stateDirectory: string; + readonly owner?: GenerationOwner; + readonly bytes?: number; + readonly filename?: string; +} + +export interface EmergencyReserveSnapshot { + readonly path: string; + readonly logicalBytes: number; + readonly allocatedBytes: number; + readonly capacityBytes: number; +} + +/** + * A generation-local, physically allocated emergency reserve. Construction is + * import-inert and opening it does not grow it: callers first recover/checkpoint + * SQLite, then call replenish exactly once. Truncation is the only way reserved + * blocks are released, so unrelated writers cannot consume the reserve itself. + */ +export class GenerationEmergencyReserve { + readonly path: string; + readonly capacityBytes: number; + readonly #fd: number; + readonly #owner: GenerationOwner; + #closed = false; + + constructor(options: EmergencyReserveOptions) { + const stateDirectory = resolve(options.stateDirectory); + const filename = options.filename ?? ".agent-host-emergency.reserve"; + if ( + basename(filename) !== filename || + filename === "." || + filename === ".." + ) + throw new Error("invalid Agent Host reserve filename"); + const bytes = options.bytes ?? LEDGER_PROTECTED_PHYSICAL_BYTES; + if (!Number.isSafeInteger(bytes) || bytes <= 0) + throw new Error("invalid Agent Host reserve size"); + const uid = options.owner?.uid ?? process.getuid?.(); + const gid = options.owner?.gid ?? process.getgid?.(); + if ( + !Number.isSafeInteger(uid) || + uid! < 0 || + !Number.isSafeInteger(gid) || + gid! < 0 + ) + throw new Error("Agent Host reserve requires an exact owner"); + const directory = lstatSync(stateDirectory); + if (!directory.isDirectory() || directory.isSymbolicLink()) + throw new Error("unsafe Agent Host generation StateDirectory"); + this.path = join(stateDirectory, filename); + this.capacityBytes = bytes; + this.#owner = { uid: uid!, gid: gid! }; + this.#fd = openSync( + this.path, + constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, + RESERVE_MODE, + ); + try { + const stat = fstatSync(this.#fd); + if (!stat.isFile() || stat.nlink !== 1) + throw new Error("unsafe Agent Host emergency reserve"); + fchownSync(this.#fd, this.#owner.uid, this.#owner.gid); + fchmodSync(this.#fd, RESERVE_MODE); + if (stat.size > bytes) ftruncateSync(this.#fd, bytes); + this.#syncDirectory(); + } catch (error) { + closeSync(this.#fd); + throw error; + } + } + + snapshot(): EmergencyReserveSnapshot { + this.#open(); + const stat = fstatSync(this.#fd); + return { + path: this.path, + logicalBytes: stat.size, + allocatedBytes: Number(stat.blocks) * 512, + capacityBytes: this.capacityBytes, + }; + } + + /** Releases at most one reserve capacity, rounded to the filesystem block. */ + consume(requiredBytes: number): number { + this.#open(); + if (!Number.isSafeInteger(requiredBytes) || requiredBytes <= 0) + throw new Error("invalid Agent Host reserve consumption"); + const before = this.snapshot(); + const fsBlock = Number(statfsSync(this.path).bsize); + const wanted = Math.ceil(requiredBytes / fsBlock) * fsBlock; + const released = Math.min(wanted, before.logicalBytes); + if (released < requiredBytes) + throw new Error("Agent Host emergency reserve is exhausted"); + ftruncateSync(this.#fd, before.logicalBytes - released); + fdatasyncSync(this.#fd); + this.#syncDirectory(); + return released; + } + + /** Performs one physical allocation pass and fails rather than retrying. */ + replenish(): void { + this.#open(); + let offset = fstatSync(this.#fd).size; + while (offset < this.capacityBytes) { + const length = Math.min( + ZERO_CHUNK.byteLength, + this.capacityBytes - offset, + ); + const written = writeSync(this.#fd, ZERO_CHUNK, 0, length, offset); + if (written !== length) + throw new Error("short Agent Host reserve allocation"); + offset += written; + } + fdatasyncSync(this.#fd); + this.#syncDirectory(); + const after = this.snapshot(); + if ( + after.logicalBytes !== this.capacityBytes || + after.allocatedBytes < this.capacityBytes + ) + throw new Error("Agent Host reserve is not physically allocated"); + // Reassert exact metadata after recovery of a pre-existing partial file. + fchownSync(this.#fd, this.#owner.uid, this.#owner.gid); + fchmodSync(this.#fd, RESERVE_MODE); + } + + close(): void { + if (this.#closed) return; + closeSync(this.#fd); + this.#closed = true; + } + + #syncDirectory(): void { + const fd = openSync( + dirname(this.path), + constants.O_RDONLY | constants.O_DIRECTORY, + ); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } + } + + #open(): void { + if (this.#closed) throw new Error("Agent Host emergency reserve is closed"); + } +} diff --git a/packages/core/opensession-server/src/agent-host/generation-supervisor.test.ts b/packages/core/opensession-server/src/agent-host/generation-supervisor.test.ts new file mode 100644 index 0000000000..7a8acfab2e --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/generation-supervisor.test.ts @@ -0,0 +1,568 @@ +import { describe, expect, test } from "bun:test"; +import { + AgentHostGenerationSupervisor, + type AgentHostAdmissionStorage, + type AgentHostGenerationIdentity, + type AgentHostGenerationManifest, + type AgentHostSystemdController, + type AgentHostTurnFence, + type AgentHostTurnPinStorage, + type AgentHostTurnReceiptVerifier, + type PersistedAdmission, + type PersistedTurnPin, + type PersistedTurnPins, +} from "./generation-supervisor"; + +const digest = (character: string) => character.repeat(64); +const terminalReceipt = "terminal-receipt"; +const deletionReceipt = "deletion-receipt"; + +class Clock { + constructor(public value = 1_000) {} + nowMs() { + return this.value; + } +} + +class AdmissionStorage implements AgentHostAdmissionStorage { + value: unknown = { version: 1, revision: 0, active: null }; + async read() { + return structuredClone(this.value); + } + async compareAndSwap(expectedRevision: number, next: PersistedAdmission) { + const current = this.value as PersistedAdmission; + if (current.revision !== expectedRevision) return false; + this.value = structuredClone(next); + return true; + } +} + +class PinStorage implements AgentHostTurnPinStorage { + value: unknown = { version: 1, revision: 0, pins: [] }; + failCas = false; + throwAfterCommit = false; + async read() { + return structuredClone(this.value); + } + async compareAndSwap(expectedRevision: number, next: PersistedTurnPins) { + const current = this.value as PersistedTurnPins; + if (this.failCas || current.revision !== expectedRevision) return false; + this.value = structuredClone(next); + if (this.throwAfterCommit) { + this.throwAfterCommit = false; + throw new Error("simulated process crash after durable CAS"); + } + return true; + } +} + +class ReceiptVerifier implements AgentHostTurnReceiptVerifier { + async verifyTerminalReceipt( + receipt: unknown, + expectedPin: Readonly, + ): Promise { + if (receipt === "ambiguous-receipt") + throw new Error("receipt authority unavailable"); + if (receipt === "cross-fence-receipt") + return { + kind: "terminal", + receiptId: "cross-fence-receipt", + pin: { + ...expectedPin, + fence: { ...expectedPin.fence, runId: "different-run" }, + }, + }; + if (receipt === "cross-generation-receipt") + return { + kind: "terminal", + receiptId: "cross-generation-receipt", + pin: { + ...expectedPin, + fence: { + ...expectedPin.fence, + generation: expectedPin.fence.generation + 1, + }, + }, + }; + if (receipt !== terminalReceipt) return false; + return { kind: "terminal", receiptId: terminalReceipt, pin: expectedPin }; + } + + async verifySessionDeletionReceipt( + receipt: unknown, + expectedSessionId: string, + expectedPins: readonly Readonly[], + ): Promise { + if (receipt === "ambiguous-receipt") + throw new Error("receipt authority unavailable"); + if (receipt === "cross-session-receipt") + return { + kind: "session-deletion", + receiptId: "cross-session-receipt", + sessionId: "different-session", + pins: expectedPins, + }; + if (receipt === "cross-generation-deletion") + return { + kind: "session-deletion", + receiptId: "cross-generation-deletion", + sessionId: expectedSessionId, + pins: expectedPins.map((pin, index) => + index === 0 + ? { + ...pin, + fence: { ...pin.fence, generation: pin.fence.generation + 1 }, + } + : pin, + ), + }; + if (receipt !== deletionReceipt) return false; + return { + kind: "session-deletion", + receiptId: deletionReceipt, + sessionId: expectedSessionId, + pins: expectedPins, + }; + } +} + +class Controller implements AgentHostSystemdController { + starts: AgentHostGenerationManifest[] = []; + stops: AgentHostGenerationIdentity[] = []; + async startGeneration(manifest: AgentHostGenerationManifest) { + this.starts.push(manifest); + } + async stopGeneration(identity: AgentHostGenerationIdentity) { + this.stops.push(identity); + } +} + +function manifest( + generation: number, + overrides: Partial = {}, +): AgentHostGenerationManifest { + return { + hostId: "agent-host", + generation, + incarnation: `incarnation-${generation}`, + releaseDigest: digest(String(generation % 10)), + protocolDigest: digest("a"), + keyringDigest: digest("b"), + recoveryLedgerId: `ledger-${generation}`, + bornAtMs: 1_000, + deadlineMs: 10_000, + ...overrides, + }; +} + +function turn( + turnId: string, + overrides: Partial = {}, +): AgentHostTurnFence { + return { + sessionId: "session-1", + runId: "run-1", + turnId, + generation: 1, + ...overrides, + }; +} + +function supervisor( + clock = new Clock(), + admission = new AdmissionStorage(), + pins = new PinStorage(), + controller = new Controller(), + verifier = new ReceiptVerifier(), +) { + return new AgentHostGenerationSupervisor( + clock, + admission, + controller, + pins, + verifier, + ); +} + +async function ready( + authority: AgentHostGenerationSupervisor, + value: AgentHostGenerationManifest, +) { + await authority.stage(value); + authority.markEligible(value); +} + +async function readyAndRecover( + authority: AgentHostGenerationSupervisor, + ...values: AgentHostGenerationManifest[] +) { + for (const value of values) await ready(authority, value); + await authority.recoverAdmission(); +} + +describe("AgentHostGenerationSupervisor durable turn ownership", () => { + test("is import-inert and validates immutable generation and turn fences", async () => { + const admission = new AdmissionStorage(); + const pins = new PinStorage(); + const controller = new Controller(); + const authority = supervisor(new Clock(), admission, pins, controller); + expect(controller.starts).toEqual([]); + expect(await pins.read()).toEqual({ version: 1, revision: 0, pins: [] }); + + await expect( + authority.stage(manifest(1, { releaseDigest: "bad" })), + ).rejects.toThrow("Invalid Agent Host generation manifest"); + const blue = manifest(1); + await readyAndRecover(authority, blue); + await authority.promote(blue); + await expect( + authority.admitNewTurn({ + sessionId: "session", + runId: "run", + turnId: "", + generation: 1, + }), + ).rejects.toThrow("Invalid Agent Host turn fence"); + await expect( + authority.admitNewTurn( + turn("unsafe", { generation: Number.MAX_SAFE_INTEGER + 1 }), + ), + ).rejects.toThrow("Invalid Agent Host turn fence"); + }); + + test("restart mid-turn reconstructs blue ownership while new admissions use green", async () => { + const admission = new AdmissionStorage(); + const pins = new PinStorage(); + const clock = new Clock(); + const blue = manifest(1); + const green = manifest(2); + const blueTurn = turn("turn-blue"); + + const first = supervisor(clock, admission, pins); + await readyAndRecover(first, blue, green); + await first.promote(blue); + expect((await first.admitNewTurn(blueTurn)).generation).toBe(1); + await first.promote(green); + + const restarted = supervisor(clock, admission, pins); + await readyAndRecover(restarted, blue, green); + expect(restarted.targetForExistingTurn(blueTurn, blue).generation).toBe(1); + expect((await restarted.admitNewTurn(turn("turn-green"))).generation).toBe( + 2, + ); + expect(() => restarted.targetForExistingTurn(blueTurn, green)).toThrow( + "Stale", + ); + expect( + (pins.value as PersistedTurnPins).pins.map((pin) => [ + pin.generationDigest, + pin.generationEpoch, + ]), + ).toEqual([ + [blue.releaseDigest, 1], + [green.releaseDigest, 2], + ]); + }); + + test("same logical turn IDs in different run generations remain distinct", async () => { + const admission = new AdmissionStorage(); + const pins = new PinStorage(); + const blue = manifest(1); + const green = manifest(2); + const firstGeneration = turn("reused", { generation: 41 }); + const nextGeneration = turn("reused", { generation: 42 }); + const authority = supervisor(new Clock(), admission, pins); + await readyAndRecover(authority, blue, green); + await authority.promote(blue); + expect((await authority.admitNewTurn(firstGeneration)).generation).toBe(1); + await authority.promote(green); + expect((await authority.admitNewTurn(nextGeneration)).generation).toBe(2); + + expect( + authority.targetForExistingTurn(firstGeneration, blue).generation, + ).toBe(1); + expect( + authority.targetForExistingTurn(nextGeneration, green).generation, + ).toBe(2); + expect(() => authority.targetForExistingTurn(nextGeneration, blue)).toThrow( + "Stale", + ); + expect((pins.value as PersistedTurnPins).pins).toHaveLength(2); + + const restarted = supervisor(new Clock(), admission, pins); + await readyAndRecover(restarted, blue, green); + expect( + restarted.targetForExistingTurn(firstGeneration, blue).generation, + ).toBe(1); + expect( + restarted.targetForExistingTurn(nextGeneration, green).generation, + ).toBe(2); + }); + + test("terminal evidence releases exactly one turn and permits retirement", async () => { + const controller = new Controller(); + const authority = supervisor( + new Clock(), + new AdmissionStorage(), + new PinStorage(), + controller, + ); + const blue = manifest(1); + const green = manifest(2); + const owned = turn("owned"); + await readyAndRecover(authority, blue, green); + await authority.promote(blue); + await authority.admitNewTurn(owned); + await authority.promote(green); + await expect(authority.retire(blue)).rejects.toThrow("owned"); + expect(await authority.releaseTurn(owned, blue, terminalReceipt)).toBe( + true, + ); + await authority.retire(blue); + expect(controller.stops).toEqual([blue]); + }); + + test("forged, cross-fence, stale, and ambiguous receipts never release", async () => { + const authority = supervisor(); + const blue = manifest(1); + const owned = turn("receipt-fenced"); + await readyAndRecover(authority, blue); + await authority.promote(blue); + await authority.admitNewTurn(owned); + + const failures: readonly unknown[] = [ + { + authenticated: true, + durable: true, + evidenceId: "caller-asserted", + }, + "cross-fence-receipt", + "cross-generation-receipt", + "stale-receipt", + "ambiguous-receipt", + ]; + for (const receipt of failures) { + await expect( + authority.releaseTurn(owned, blue, receipt), + ).rejects.toThrow(); + expect(authority.targetForExistingTurn(owned, blue).generation).toBe(1); + } + }); + + test("authenticated durable session deletion releases all session pins only", async () => { + const authority = supervisor(); + const blue = manifest(1); + await readyAndRecover(authority, blue); + await authority.promote(blue); + await authority.admitNewTurn(turn("one")); + await authority.admitNewTurn(turn("two")); + await authority.admitNewTurn( + turn("other", { sessionId: "session-2", runId: "run-2" }), + ); + for (const receipt of [ + { authenticated: true, durable: true }, + "cross-session-receipt", + "cross-generation-deletion", + "stale-receipt", + "ambiguous-receipt", + ]) { + await expect( + authority.releaseSessionTurns("session-1", receipt), + ).rejects.toThrow(); + expect( + authority.targetForExistingTurn(turn("one"), blue).generation, + ).toBe(1); + } + expect( + await authority.releaseSessionTurns("session-1", deletionReceipt), + ).toBe(2); + expect(() => authority.targetForExistingTurn(turn("one"), blue)).toThrow( + "Stale", + ); + expect( + authority.targetForExistingTurn( + turn("other", { sessionId: "session-2", runId: "run-2" }), + blue, + ).generation, + ).toBe(1); + }); + + test("terminal and deletion receipts cannot cross reused run generations", async () => { + const authority = supervisor(); + const blue = manifest(1); + const firstGeneration = turn("same", { generation: 7 }); + const nextGeneration = turn("same", { generation: 8 }); + await readyAndRecover(authority, blue); + await authority.promote(blue); + await authority.admitNewTurn(firstGeneration); + await authority.admitNewTurn(nextGeneration); + + await expect( + authority.releaseTurn(firstGeneration, blue, "cross-generation-receipt"), + ).rejects.toThrow("terminal receipt verification failed"); + expect( + authority.targetForExistingTurn(nextGeneration, blue).generation, + ).toBe(1); + await expect( + authority.releaseSessionTurns("session-1", "cross-generation-deletion"), + ).rejects.toThrow("deletion receipt verification failed"); + expect( + authority.targetForExistingTurn(firstGeneration, blue).generation, + ).toBe(1); + expect( + authority.targetForExistingTurn(nextGeneration, blue).generation, + ).toBe(1); + }); + + test("a crash after durable pin CAS is recovered without reassignment", async () => { + const admission = new AdmissionStorage(); + const pins = new PinStorage(); + const blue = manifest(1); + const owned = turn("crash"); + const first = supervisor(new Clock(), admission, pins); + await readyAndRecover(first, blue); + await first.promote(blue); + pins.throwAfterCommit = true; + await expect(first.admitNewTurn(owned)).rejects.toThrow( + "simulated process crash", + ); + + const restarted = supervisor(new Clock(), admission, pins); + await readyAndRecover(restarted, blue); + expect(restarted.targetForExistingTurn(owned, blue).generation).toBe(1); + expect((await restarted.admitNewTurn(owned)).generation).toBe(1); + }); + + test("pin CAS races choose one exact generation and losers fail closed", async () => { + const admission = new AdmissionStorage(); + const pins = new PinStorage(); + const blue = manifest(1); + const left = supervisor(new Clock(), admission, pins); + const right = supervisor(new Clock(), admission, pins); + await readyAndRecover(left, blue); + await readyAndRecover(right, blue); + await left.promote(blue); + await right.recoverAdmission(); + + const firstGeneration = turn("race", { generation: 1 }); + const nextGeneration = turn("race", { generation: 2 }); + const results = await Promise.allSettled([ + left.admitNewTurn(firstGeneration), + right.admitNewTurn(nextGeneration), + ]); + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + const persisted = pins.value as PersistedTurnPins; + expect(persisted.pins).toHaveLength(1); + expect([1, 2]).toContain(persisted.pins[0]!.fence.generation); + + const restarted = supervisor(new Clock(), admission, pins); + await readyAndRecover(restarted, blue); + await restarted.admitNewTurn(firstGeneration); + await restarted.admitNewTurn(nextGeneration); + expect((pins.value as PersistedTurnPins).pins).toHaveLength(2); + }); + + test("24h deadline reports blocked/indeterminate and never retires or reassigns", async () => { + const clock = new Clock(); + const deadline = 1_000 + 24 * 60 * 60 * 1_000; + const blue = manifest(1, { deadlineMs: deadline }); + const green = manifest(2, { deadlineMs: deadline }); + const authority = supervisor(clock); + const owned = turn("indeterminate"); + await readyAndRecover(authority, blue, green); + await authority.promote(blue); + await authority.admitNewTurn(owned); + await authority.promote(green); + clock.value = deadline; + + expect(authority.snapshot(blue)?.state).toBe("blocked"); + expect(authority.targetForExistingTurn(owned, blue).generation).toBe(1); + await expect(authority.retire(blue)).rejects.toThrow("owned"); + expect( + authority.deletionBroadcastTargets().map((item) => item.generation), + ).toContain(1); + }); + + test("tamper, stale generation, and session/run crossover fail closed", async () => { + const admission = new AdmissionStorage(); + const pins = new PinStorage(); + const blue = manifest(1); + const owned = turn("owned"); + const first = supervisor(new Clock(), admission, pins); + await readyAndRecover(first, blue); + await first.promote(blue); + await first.admitNewTurn(owned); + + expect(() => + first.targetForExistingTurn({ ...owned, runId: "other-run" }, blue), + ).toThrow("Stale"); + expect(() => + first.targetForExistingTurn(owned, { ...blue, incarnation: "stale" }), + ).toThrow("Stale"); + expect(() => + first.targetForExistingTurn(owned, { + ...blue, + keyringDigest: digest("c"), + }), + ).toThrow("Stale"); + + const persisted = pins.value as PersistedTurnPins; + pins.value = { + ...persisted, + pins: [{ ...persisted.pins[0], generationDigest: digest("f") }], + }; + const tampered = supervisor(new Clock(), admission, pins); + await ready(tampered, blue); + await expect(tampered.recoverAdmission()).rejects.toThrow( + "Invalid persisted Agent Host turn pins", + ); + await expect(tampered.admitNewTurn(turn("closed"))).rejects.toThrow( + "admission is closed", + ); + + pins.value = { + ...persisted, + pins: [ + { + ...persisted.pins[0], + generation: { + ...persisted.pins[0]!.generation, + keyringDigest: digest("c"), + }, + }, + ], + }; + const staleKey = supervisor(new Clock(), admission, pins); + await ready(staleKey, blue); + await expect(staleKey.recoverAdmission()).rejects.toThrow( + "turn generation is unavailable", + ); + + pins.value = { + ...persisted, + pins: [{ ...persisted.pins[0], generationEpoch: 99 }], + }; + const staleEpoch = supervisor(new Clock(), admission, pins); + await ready(staleEpoch, blue); + await expect(staleEpoch.recoverAdmission()).rejects.toThrow( + "Invalid persisted Agent Host turn pins", + ); + + const { generation: _staleGeneration, ...threeFieldFence } = + persisted.pins[0]!.fence; + pins.value = { + ...persisted, + pins: [{ ...persisted.pins[0], fence: threeFieldFence }], + }; + const legacyPin = supervisor(new Clock(), admission, pins); + await ready(legacyPin, blue); + await expect(legacyPin.recoverAdmission()).rejects.toThrow( + "Invalid persisted Agent Host turn pins", + ); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/generation-supervisor.ts b/packages/core/opensession-server/src/agent-host/generation-supervisor.ts new file mode 100644 index 0000000000..f56fcdfb2a --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/generation-supervisor.ts @@ -0,0 +1,849 @@ +const DAY_MS = 24 * 60 * 60 * 1000; +const DIGEST_RE = /^[a-f0-9]{64}$/; +const TOKEN_RE = /^[A-Za-z0-9._:-]{1,128}$/; + +export type AgentHostGenerationState = + | "admission-closed" + | "eligible" + | "active" + | "draining" + | "blocked" + | "expired"; + +export interface AgentHostGenerationIdentity { + readonly hostId: string; + readonly generation: number; + readonly incarnation: string; +} + +export interface AgentHostGenerationManifest extends AgentHostGenerationIdentity { + readonly releaseDigest: string; + readonly protocolDigest: string; + readonly keyringDigest: string; + readonly recoveryLedgerId: string; + readonly bornAtMs: number; + readonly deadlineMs: number; +} + +export interface AgentHostGenerationRecord extends AgentHostGenerationManifest { + readonly state: AgentHostGenerationState; + readonly healthy: boolean; +} + +export interface AgentHostClock { + nowMs(): number; +} + +/** Persistence must implement the compare-and-swap atomically. */ +export interface AgentHostAdmissionStorage { + read(): Promise; + compareAndSwap( + expectedRevision: number, + next: PersistedAdmission, + ): Promise; +} + +/** The session/run/turn identity authenticated by the gateway. */ +export interface AgentHostTurnFence { + readonly sessionId: string; + readonly runId: string; + readonly turnId: string; + readonly generation: number; +} + +export interface PersistedTurnPin { + readonly fence: AgentHostTurnFence; + readonly generation: AgentHostGenerationManifest; + /** Redundant, explicit rollout fence. It must match generation.releaseDigest. */ + readonly generationDigest: string; + /** Redundant, explicit rollout epoch. It must match generation.generation. */ + readonly generationEpoch: number; + readonly pinnedAtMs: number; +} + +export interface PersistedTurnPins { + readonly version: 1; + readonly revision: number; + readonly pins: readonly PersistedTurnPin[]; +} + +/** Persistence must implement compare-and-swap atomically and durably. */ +export interface AgentHostTurnPinStorage { + read(): Promise; + compareAndSwap( + expectedRevision: number, + next: PersistedTurnPins, + ): Promise; +} + +export interface AgentHostVerifiedTerminalReceipt { + readonly kind: "terminal"; + readonly receiptId: string; + readonly pin: PersistedTurnPin; +} + +export interface AgentHostVerifiedDeletionReceipt { + readonly kind: "session-deletion"; + readonly receiptId: string; + readonly sessionId: string; + readonly pins: readonly PersistedTurnPin[]; +} + +/** + * Authentication authority for opaque durable receipts. Implementations must + * return canonical verification data from their authoritative store, or false. + */ +export interface AgentHostTurnReceiptVerifier { + verifyTerminalReceipt( + receipt: unknown, + expectedPin: Readonly, + ): Promise; + verifySessionDeletionReceipt( + receipt: unknown, + expectedSessionId: string, + expectedPins: readonly Readonly[], + ): Promise; +} + +/** An injected controller. Implementations may use systemd; this module never does. */ +export interface AgentHostSystemdController { + startGeneration(manifest: AgentHostGenerationManifest): Promise; + stopGeneration(identity: AgentHostGenerationIdentity): Promise; +} + +export interface PersistedAdmission { + readonly version: 1; + readonly revision: number; + readonly active: AgentHostGenerationManifest | null; +} + +interface MutableGeneration { + manifest: Readonly; + state: AgentHostGenerationState; + healthy: boolean; +} + +const own = (value: object, key: PropertyKey): boolean => + Object.prototype.hasOwnProperty.call(value, key); + +function isSafeTime(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function validIdentity(value: unknown): value is AgentHostGenerationIdentity { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as Record; + return ( + Object.keys(candidate).length === 3 && + typeof candidate.hostId === "string" && + TOKEN_RE.test(candidate.hostId) && + Number.isSafeInteger(candidate.generation) && + (candidate.generation as number) >= 0 && + typeof candidate.incarnation === "string" && + TOKEN_RE.test(candidate.incarnation) + ); +} + +function decodePersisted(value: unknown): PersistedAdmission | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) + return undefined; + const candidate = value as Record; + if ( + Object.keys(candidate).length !== 3 || + !own(candidate, "version") || + !own(candidate, "revision") || + !own(candidate, "active") || + candidate.version !== 1 || + !Number.isSafeInteger(candidate.revision) || + (candidate.revision as number) < 0 + ) + return undefined; + try { + return Object.freeze({ + version: 1, + revision: candidate.revision as number, + active: + candidate.active === null + ? null + : exactManifest(candidate.active as AgentHostGenerationManifest), + }); + } catch { + return undefined; + } +} + +function identityKey(identity: AgentHostGenerationIdentity): string { + return JSON.stringify([ + identity.hostId, + identity.generation, + identity.incarnation, + ]); +} + +function sameIdentity( + left: AgentHostGenerationIdentity, + right: AgentHostGenerationIdentity, +): boolean { + return ( + left.hostId === right.hostId && + left.generation === right.generation && + left.incarnation === right.incarnation + ); +} + +const MANIFEST_KEYS = [ + "hostId", + "generation", + "incarnation", + "releaseDigest", + "protocolDigest", + "keyringDigest", + "recoveryLedgerId", + "bornAtMs", + "deadlineMs", +] as const; + +function exactManifest( + manifest: AgentHostGenerationManifest, +): Readonly { + if ( + !manifest || + typeof manifest !== "object" || + Array.isArray(manifest) || + Object.keys(manifest).length !== MANIFEST_KEYS.length || + !Object.keys(manifest).every((key) => + MANIFEST_KEYS.includes(key as (typeof MANIFEST_KEYS)[number]), + ) || + !validIdentity({ + hostId: manifest.hostId, + generation: manifest.generation, + incarnation: manifest.incarnation, + }) || + typeof manifest.releaseDigest !== "string" || + !DIGEST_RE.test(manifest.releaseDigest) || + typeof manifest.protocolDigest !== "string" || + !DIGEST_RE.test(manifest.protocolDigest) || + typeof manifest.keyringDigest !== "string" || + !DIGEST_RE.test(manifest.keyringDigest) || + typeof manifest.recoveryLedgerId !== "string" || + !TOKEN_RE.test(manifest.recoveryLedgerId) || + !isSafeTime(manifest.bornAtMs) || + !isSafeTime(manifest.deadlineMs) || + manifest.deadlineMs <= manifest.bornAtMs || + manifest.deadlineMs - manifest.bornAtMs > DAY_MS + ) + throw new Error("Invalid Agent Host generation manifest"); + return Object.freeze({ ...manifest }); +} + +function sameManifest( + left: AgentHostGenerationManifest, + right: AgentHostGenerationManifest, +): boolean { + return MANIFEST_KEYS.every((key) => left[key] === right[key]); +} + +const TURN_FENCE_KEYS = ["sessionId", "runId", "turnId", "generation"] as const; +const TURN_FENCE_TOKEN_KEYS = ["sessionId", "runId", "turnId"] as const; + +function exactTurnFence( + value: AgentHostTurnFence, +): Readonly { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).length !== TURN_FENCE_KEYS.length || + !Object.keys(value).every((key) => + TURN_FENCE_KEYS.includes(key as (typeof TURN_FENCE_KEYS)[number]), + ) || + !TURN_FENCE_TOKEN_KEYS.every( + (key) => typeof value[key] === "string" && TOKEN_RE.test(value[key]), + ) || + !Number.isSafeInteger(value.generation) || + value.generation < 0 + ) + throw new Error("Invalid Agent Host turn fence"); + return Object.freeze({ ...value }); +} + +function turnFenceKey(fence: AgentHostTurnFence): string { + return JSON.stringify([ + fence.sessionId, + fence.runId, + fence.turnId, + fence.generation, + ]); +} + +function exactTurnPin(value: PersistedTurnPin): Readonly { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).length !== 5 || + ![ + "fence", + "generation", + "generationDigest", + "generationEpoch", + "pinnedAtMs", + ].every((key) => own(value, key)) + ) + throw new Error("Invalid persisted Agent Host turn pin"); + const fence = exactTurnFence(value.fence); + const generation = exactManifest(value.generation); + if ( + value.generationDigest !== generation.releaseDigest || + value.generationEpoch !== generation.generation || + !isSafeTime(value.pinnedAtMs) || + value.pinnedAtMs < generation.bornAtMs || + value.pinnedAtMs >= generation.deadlineMs + ) + throw new Error("Invalid persisted Agent Host turn pin"); + return Object.freeze({ + fence, + generation, + generationDigest: value.generationDigest, + generationEpoch: value.generationEpoch, + pinnedAtMs: value.pinnedAtMs, + }); +} + +function decodePersistedTurnPins( + value: unknown, +): PersistedTurnPins | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + const candidate = value as Record; + if ( + Object.keys(candidate).length !== 3 || + candidate.version !== 1 || + !Number.isSafeInteger(candidate.revision) || + (candidate.revision as number) < 0 || + !Array.isArray(candidate.pins) + ) + return; + try { + const pins = candidate.pins.map((pin) => exactTurnPin(pin)); + const keys = pins.map((pin) => turnFenceKey(pin.fence)); + if (new Set(keys).size !== keys.length) return; + return Object.freeze({ + version: 1, + revision: candidate.revision as number, + pins: Object.freeze(pins), + }); + } catch { + return; + } +} + +function sameTurnFence( + left: AgentHostTurnFence, + right: AgentHostTurnFence, +): boolean { + return TURN_FENCE_KEYS.every((key) => left[key] === right[key]); +} + +function sameTurnPin(left: PersistedTurnPin, right: PersistedTurnPin): boolean { + return ( + sameTurnFence(left.fence, right.fence) && + sameManifest(left.generation, right.generation) && + left.generationDigest === right.generationDigest && + left.generationEpoch === right.generationEpoch && + left.pinnedAtMs === right.pinnedAtMs + ); +} + +function decodeVerifiedTerminalReceipt( + value: unknown, +): AgentHostVerifiedTerminalReceipt | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + const candidate = value as Record; + if ( + Object.keys(candidate).length !== 3 || + candidate.kind !== "terminal" || + typeof candidate.receiptId !== "string" || + !TOKEN_RE.test(candidate.receiptId) + ) + return; + try { + return Object.freeze({ + kind: "terminal", + receiptId: candidate.receiptId, + pin: exactTurnPin(candidate.pin as PersistedTurnPin), + }); + } catch { + return; + } +} + +function decodeVerifiedDeletionReceipt( + value: unknown, +): AgentHostVerifiedDeletionReceipt | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + const candidate = value as Record; + if ( + Object.keys(candidate).length !== 4 || + candidate.kind !== "session-deletion" || + typeof candidate.receiptId !== "string" || + !TOKEN_RE.test(candidate.receiptId) || + typeof candidate.sessionId !== "string" || + !TOKEN_RE.test(candidate.sessionId) || + !Array.isArray(candidate.pins) + ) + return; + try { + const pins = candidate.pins.map((pin) => exactTurnPin(pin)); + const keys = pins.map((pin) => turnFenceKey(pin.fence)); + if (new Set(keys).size !== keys.length) return; + return Object.freeze({ + kind: "session-deletion", + receiptId: candidate.receiptId, + sessionId: candidate.sessionId, + pins: Object.freeze(pins), + }); + } catch { + return; + } +} + +function samePinSet( + left: readonly PersistedTurnPin[], + right: readonly PersistedTurnPin[], +): boolean { + if (left.length !== right.length) return false; + const rightByFence = new Map( + right.map((pin) => [turnFenceKey(pin.fence), pin] as const), + ); + return left.every((pin) => { + const other = rightByFence.get(turnFenceKey(pin.fence)); + return !!other && sameTurnPin(pin, other); + }); +} + +/** + * Import-inert blue/green generation authority. All effects are explicit and + * injected. A generation's ledger ID is unique, so no two live generations can + * be writers for the same recovery ledger. + */ +export class AgentHostGenerationSupervisor { + private readonly generations = new Map(); + private readonly ledgerOwners = new Map(); + private readonly turnPins = new Map>(); + private activeKey: string | undefined; + private persistedRevision: number | undefined; + private pinRevision: number | undefined; + private mutationTail: Promise = Promise.resolve(); + + constructor( + private readonly clock: AgentHostClock, + private readonly storage: AgentHostAdmissionStorage, + private readonly controller: AgentHostSystemdController, + private readonly pinStorage: AgentHostTurnPinStorage, + private readonly receiptVerifier: AgentHostTurnReceiptVerifier, + ) {} + + private serialize(operation: () => Promise): Promise { + const result = this.mutationTail.then(operation, operation); + this.mutationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private expireDue(): void { + const now = this.clock.nowMs(); + for (const [key, generation] of this.generations) { + if ( + generation.state !== "expired" && + generation.state !== "blocked" && + now >= generation.manifest.deadlineMs + ) { + const hasPins = [...this.turnPins.values()].some((pin) => + sameIdentity(pin.generation, generation.manifest), + ); + generation.state = hasPins ? "blocked" : "expired"; + generation.healthy = false; + if (this.activeKey === key) this.activeKey = undefined; + } + } + } + + private getExact(identity: AgentHostGenerationIdentity): MutableGeneration { + const generation = this.generations.get(identityKey(identity)); + if (!generation || !sameIdentity(generation.manifest, identity)) + throw new Error("Stale Agent Host generation fence"); + this.expireDue(); + if (generation.state === "expired") + throw new Error("Expired Agent Host generation fence"); + return generation; + } + + async stage( + manifestValue: AgentHostGenerationManifest, + ): Promise { + return this.serialize(async () => { + const manifest = exactManifest(manifestValue); + const key = identityKey(manifest); + if (this.generations.has(key)) + throw new Error("Duplicate Agent Host generation"); + if ( + [...this.generations.values()].some( + (entry) => + entry.manifest.hostId === manifest.hostId && + entry.manifest.generation === manifest.generation, + ) + ) + throw new Error("Agent Host generation incarnation conflict"); + if (this.ledgerOwners.has(manifest.recoveryLedgerId)) + throw new Error("Agent Host recovery ledger already has a writer"); + if (this.clock.nowMs() >= manifest.deadlineMs) + throw new Error("Cannot stage an expired Agent Host generation"); + + await this.controller.startGeneration(manifest); + this.generations.set(key, { + manifest, + state: "admission-closed", + healthy: false, + }); + this.ledgerOwners.set(manifest.recoveryLedgerId, key); + return this.snapshot(key)!; + }); + } + + markEligible( + identity: AgentHostGenerationIdentity, + ): AgentHostGenerationRecord { + const generation = this.getExact(identity); + if ( + generation.state !== "admission-closed" && + generation.state !== "eligible" + ) + throw new Error("Agent Host generation cannot become eligible"); + generation.healthy = true; + generation.state = "eligible"; + return this.snapshot(identityKey(identity))!; + } + + async closeAdmission(identity: AgentHostGenerationIdentity): Promise { + return this.serialize(async () => { + const generation = this.getExact(identity); + generation.healthy = false; + const wasActive = generation.state === "active"; + if (generation.state !== "draining" && generation.state !== "blocked") + generation.state = "admission-closed"; + if (this.activeKey === identityKey(identity)) this.activeKey = undefined; + if (wasActive) await this.persistActive(null); + }); + } + + /** Fail closed unless the persisted exact incarnation is locally healthy and eligible. */ + async recoverAdmission(): Promise { + return this.serialize(async () => { + this.activeKey = undefined; + for (const generation of this.generations.values()) { + if (generation.state === "active") + generation.state = "admission-closed"; + } + this.turnPins.clear(); + const [persistedValue, persistedPinsValue] = await Promise.all([ + this.storage.read(), + this.pinStorage.read(), + ]); + const persisted = decodePersisted(persistedValue); + const persistedPins = decodePersistedTurnPins(persistedPinsValue); + if (!persisted) { + this.persistedRevision = undefined; + throw new Error("Invalid persisted Agent Host admission generation"); + } + if (!persistedPins) { + this.pinRevision = undefined; + throw new Error("Invalid persisted Agent Host turn pins"); + } + for (const pin of persistedPins.pins) { + const generation = this.generations.get(identityKey(pin.generation)); + if (!generation || !sameManifest(generation.manifest, pin.generation)) { + this.pinRevision = undefined; + throw new Error( + "Persisted Agent Host turn generation is unavailable", + ); + } + } + for (const pin of persistedPins.pins) + this.turnPins.set(turnFenceKey(pin.fence), pin); + this.persistedRevision = persisted.revision; + this.pinRevision = persistedPins.revision; + this.expireDue(); + if (!persisted.active) return undefined; + const key = identityKey(persisted.active); + const generation = this.generations.get(key); + if ( + !generation || + !sameManifest(generation.manifest, persisted.active) || + generation.state !== "eligible" || + !generation.healthy + ) { + throw new Error( + "Persisted Agent Host admission generation is unavailable", + ); + } + generation.state = "active"; + this.activeKey = key; + return this.snapshot(key); + }); + } + + private async persistActive( + active: AgentHostGenerationManifest | null, + ): Promise { + const persisted = decodePersisted(await this.storage.read()); + if ( + !persisted || + (this.persistedRevision !== undefined && + persisted.revision !== this.persistedRevision) + ) { + this.activeKey = undefined; + throw new Error("Agent Host admission generation raced persistence"); + } + const next: PersistedAdmission = Object.freeze({ + version: 1, + revision: persisted.revision + 1, + active: active ? exactManifest(active) : null, + }); + if (!(await this.storage.compareAndSwap(persisted.revision, next))) { + this.activeKey = undefined; + throw new Error("Agent Host admission generation raced persistence"); + } + this.persistedRevision = next.revision; + } + + async promote( + identity: AgentHostGenerationIdentity, + options: { rollback?: boolean } = {}, + ): Promise { + return this.serialize(async () => { + const candidate = this.getExact(identity); + const key = identityKey(identity); + if ( + candidate.state !== "eligible" && + !(options.rollback && candidate.state === "draining") + ) + throw new Error("Agent Host generation is not eligible for promotion"); + if (!candidate.healthy) + throw new Error("Agent Host generation is unhealthy"); + const current = this.activeKey + ? this.generations.get(this.activeKey) + : undefined; + if (options.rollback) { + if (!current) + throw new Error("Rollback requires an active Agent Host generation"); + if ( + candidate.manifest.protocolDigest !== + current.manifest.protocolDigest || + candidate.manifest.keyringDigest !== current.manifest.keyringDigest + ) + throw new Error("Incompatible Agent Host rollback generation"); + } + await this.persistActive(candidate.manifest); + if (current && this.activeKey !== key) current.state = "draining"; + candidate.state = "active"; + this.activeKey = key; + return this.snapshot(key)!; + }); + } + + /** Durably pins before returning an admission target. */ + async admitNewTurn( + fenceValue: AgentHostTurnFence, + ): Promise { + return this.serialize(async () => { + const fence = exactTurnFence(fenceValue); + this.expireDue(); + if (!this.activeKey || this.pinRevision === undefined) + throw new Error("Agent Host admission is closed"); + const generation = this.generations.get(this.activeKey)!; + if (generation.state !== "active" || !generation.healthy) + throw new Error("Agent Host admission is closed"); + + const persisted = decodePersistedTurnPins(await this.pinStorage.read()); + if (!persisted || persisted.revision !== this.pinRevision) + throw new Error("Agent Host turn pin raced persistence"); + const fenceKey = turnFenceKey(fence); + const existing = persisted.pins.find( + (pin) => turnFenceKey(pin.fence) === fenceKey, + ); + if (existing) { + const local = this.generations.get(identityKey(existing.generation)); + if (!local || !sameManifest(local.manifest, existing.generation)) + throw new Error( + "Persisted Agent Host turn generation is unavailable", + ); + this.turnPins.set(fenceKey, existing); + return this.snapshot(identityKey(existing.generation))!; + } + + const pin = exactTurnPin({ + fence, + generation: generation.manifest, + generationDigest: generation.manifest.releaseDigest, + generationEpoch: generation.manifest.generation, + pinnedAtMs: this.clock.nowMs(), + }); + const next: PersistedTurnPins = Object.freeze({ + version: 1, + revision: persisted.revision + 1, + pins: Object.freeze([...persisted.pins, pin]), + }); + if (!(await this.pinStorage.compareAndSwap(persisted.revision, next))) + throw new Error("Agent Host turn pin raced persistence"); + this.pinRevision = next.revision; + this.turnPins.set(fenceKey, pin); + return this.snapshot(this.activeKey)!; + }); + } + + targetForExistingTurn( + fenceValue: AgentHostTurnFence, + generationFence: AgentHostGenerationManifest, + ): AgentHostGenerationRecord { + const fence = exactTurnFence(fenceValue); + const exactGenerationFence = exactManifest(generationFence); + const pin = this.turnPins.get(turnFenceKey(fence)); + if (!pin || !sameManifest(pin.generation, exactGenerationFence)) + throw new Error("Stale Agent Host generation fence"); + const generation = this.getExact(exactGenerationFence); + if (!sameManifest(generation.manifest, pin.generation)) + throw new Error("Stale Agent Host generation fence"); + return this.snapshot(identityKey(exactGenerationFence))!; + } + + async releaseTurn( + fenceValue: AgentHostTurnFence, + generationFence: AgentHostGenerationManifest, + opaqueReceipt: unknown, + ): Promise { + return this.serialize(async () => { + const fence = exactTurnFence(fenceValue); + const fenceKey = turnFenceKey(fence); + const exactGenerationFence = exactManifest(generationFence); + const pin = this.turnPins.get(fenceKey); + if (!pin || !sameManifest(pin.generation, exactGenerationFence)) + return false; + const verified = decodeVerifiedTerminalReceipt( + await this.receiptVerifier.verifyTerminalReceipt(opaqueReceipt, pin), + ); + if (!verified || !sameTurnPin(verified.pin, pin)) + throw new Error("Agent Host terminal receipt verification failed"); + return this.persistPinRemoval( + (candidate) => turnFenceKey(candidate.fence) === fenceKey, + ); + }); + } + + async releaseSessionTurns( + sessionId: string, + opaqueReceipt: unknown, + ): Promise { + return this.serialize(async () => { + if (!TOKEN_RE.test(sessionId)) + throw new Error("Invalid Agent Host session fence"); + const pins = [...this.turnPins.values()].filter( + (pin) => pin.fence.sessionId === sessionId, + ); + if (pins.length === 0) return 0; + const verified = decodeVerifiedDeletionReceipt( + await this.receiptVerifier.verifySessionDeletionReceipt( + opaqueReceipt, + sessionId, + pins, + ), + ); + if ( + !verified || + verified.sessionId !== sessionId || + !samePinSet(verified.pins, pins) + ) + throw new Error("Agent Host deletion receipt verification failed"); + await this.persistPinRemoval( + (candidate) => candidate.fence.sessionId === sessionId, + ); + return pins.length; + }); + } + + private async persistPinRemoval( + remove: (pin: Readonly) => boolean, + ): Promise { + if (this.pinRevision === undefined) + throw new Error("Agent Host turn pins have not been recovered"); + const persisted = decodePersistedTurnPins(await this.pinStorage.read()); + if (!persisted || persisted.revision !== this.pinRevision) + throw new Error("Agent Host turn pin raced persistence"); + const pins = persisted.pins.filter((pin) => !remove(pin)); + if (pins.length === persisted.pins.length) return false; + const next: PersistedTurnPins = Object.freeze({ + version: 1, + revision: persisted.revision + 1, + pins: Object.freeze(pins), + }); + if (!(await this.pinStorage.compareAndSwap(persisted.revision, next))) + throw new Error("Agent Host turn pin raced persistence"); + this.pinRevision = next.revision; + this.turnPins.clear(); + for (const pin of pins) this.turnPins.set(turnFenceKey(pin.fence), pin); + return true; + } + + deletionBroadcastTargets(): readonly AgentHostGenerationRecord[] { + this.expireDue(); + return Object.freeze( + [...this.generations.entries()] + .filter( + ([, generation]) => + generation.state === "active" || + generation.state === "draining" || + generation.state === "blocked", + ) + .map(([key]) => this.snapshot(key)!), + ); + } + + async retire(identity: AgentHostGenerationIdentity): Promise { + return this.serialize(async () => { + const key = identityKey(identity); + const generation = this.generations.get(key); + if (!generation || !sameIdentity(generation.manifest, identity)) + throw new Error("Stale Agent Host generation fence"); + this.expireDue(); + if ( + generation.state === "active" || + [...this.turnPins.values()].some((pin) => + sameIdentity(pin.generation, generation.manifest), + ) + ) + throw new Error("Cannot retire an owned Agent Host generation"); + await this.controller.stopGeneration(identity); + this.generations.delete(key); + this.ledgerOwners.delete(generation.manifest.recoveryLedgerId); + }); + } + + snapshot( + identityOrKey: AgentHostGenerationIdentity | string, + ): AgentHostGenerationRecord | undefined { + this.expireDue(); + const generation = this.generations.get( + typeof identityOrKey === "string" + ? identityOrKey + : identityKey(identityOrKey), + ); + return generation + ? Object.freeze({ + ...generation.manifest, + state: generation.state, + healthy: generation.healthy, + }) + : undefined; + } +} diff --git a/packages/core/opensession-server/src/agent-host/host.test.ts b/packages/core/opensession-server/src/agent-host/host.test.ts new file mode 100644 index 0000000000..f4bbc3914d --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/host.test.ts @@ -0,0 +1,588 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect, type Socket } from "node:net"; +import { + AGENT_HOST_PROTOCOL_VERSION, + AGENT_HOST_SUPERVISION_AUDIENCE, + AGENT_HOST_SUPERVISION_PURPOSE, + AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN, + hashAgentOperationDescriptorV1, + hashAgentTurnSpecV2, + serializeAgentHostSupervisionAuthorityV2, + type AgentHostChallengeDescriptorV4, + type AgentHostSupervisionPublicKeyringV2, + type AgentOperationReceiptV1, + type AgentTurnFence, + type AgentTurnSpec, +} from "@tellahq/opensession-protocol"; +import { createAgentHostSupervisionSigner } from "../server/session-kernel/agent-host-supervision-signer"; +import type { + AgentHostOperationTransport, + AgentTurnDriver, + AgentTurnResult, +} from "./driver"; +import { + createAgentHost, + type AgentHost, + type AgentHostFailpoint, +} from "./host"; +import { BoundedNdjsonDecoder, encodeNdjsonFrame } from "./socket-framing"; + +const fence: AgentTurnFence = { + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 3, +}; +const descriptor = { + version: 1 as const, + kind: "model" as const, + stepId: "step-1", + transcript: { + throughChangeSeq: 0, + entryIds: [], + digest: `sha256:${"a".repeat(64)}` as const, + }, + modelPolicyHash: `sha256:${"b".repeat(64)}` as const, + adapterRequestVersion: "model.v1", +}; +const descriptorDigest = await hashAgentOperationDescriptorV1(descriptor); +const now = Date.now(); +const spec: AgentTurnSpec = { + fence, + initialOperation: { + operationId: "operation-1", + descriptor, + descriptorDigest, + deadlineMs: now + 60_000, + }, + transcript: { afterChangeSeq: 0, maxAppendBytes: 4096, requireAck: true }, + limits: { + turnDeadlineMs: now + 120_000, + maxInFlightOperations: 8, + maxBufferedStreamBytes: 512 * 1024, + maxBufferedStreamChunks: 32, + }, +}; +const planHash = await hashAgentTurnSpecV2(spec); +class Driver implements AgentTurnDriver { + transport?: AgentHostOperationTransport; + delivered: number[] = []; + cancelled = 0; + private done!: (r: AgentTurnResult) => void; + completion = new Promise((r) => (this.done = r)); + constructor(private readonly requestInitialOnRun = false) {} + async run(s: AgentTurnSpec, t: AgentHostOperationTransport) { + this.transport = t; + if (this.requestInitialOnRun) await t.requestOperation(s.initialOperation); + return this.completion; + } + async deliverOperationStream(s: { streamSeq: number }) { + this.delivered.push(s.streamSeq); + } + async cancel() { + this.cancelled++; + } + async shutdown() {} + finish() { + this.done({ status: "completed" }); + } +} +function signing() { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"), + now = Date.now(), + keyId = "supervision-key-01"; + const signer = createAgentHostSupervisionSigner({ + keyId, + privateKeyPkcs8: Uint8Array.from( + privateKey.export({ type: "pkcs8", format: "der" }) as Buffer, + ), + publicKeySpki: Uint8Array.from( + publicKey.export({ type: "spki", format: "der" }) as Buffer, + ), + signingNotBeforeMs: now - 60_000, + signingNotAfterMs: now + 3_600_000, + verifyUntilMs: now + 7_200_000, + status: "active", + }); + const keyring: AgentHostSupervisionPublicKeyringV2 = { + version: 2, + algorithm: AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + domain: AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN, + keys: [ + { + keyId, + status: "active", + publicKeySpki: ( + publicKey.export({ type: "spki", format: "der" }) as Buffer + ).toString("base64url"), + signingNotBeforeMs: now - 60_000, + signingNotAfterMs: now + 3_600_000, + verifyUntilMs: now + 7_200_000, + }, + ], + }; + let epoch = 0; + return { + keyring, + receipt: (c: AgentHostChallengeDescriptorV4) => { + const issuedAtMs = Date.now(); + const expected = { + fence, + planHash, + ...c, + supervisorEpoch: ++epoch, + kernelServiceEpoch: `kernel-${epoch}`, + nonce: `nonce-${crypto.randomUUID()}`, + audience: AGENT_HOST_SUPERVISION_AUDIENCE, + purpose: AGENT_HOST_SUPERVISION_PURPOSE, + keyId, + issuedAtMs, + expiresAtMs: issuedAtMs + 60_000, + }; + return { + expected, + envelope: signer.sign( + serializeAgentHostSupervisionAuthorityV2({ version: 2, ...expected }), + issuedAtMs, + ), + }; + }, + }; +} +type Peer = { socket: Socket; messages: any[] }; +const resources: { host: AgentHost; dir: string }[] = []; +afterEach(async () => { + for (const r of resources.splice(0)) { + await r.host.stop(); + await rm(r.dir, { recursive: true, force: true }); + } +}); +async function setup( + extra: Partial[0]> = {}, +) { + const dir = await mkdtemp(join(tmpdir(), "host-v4-")), + socketPath = join(dir, "host.sock"), + driver = new Driver(), + sig = signing(), + hostIncarnation = `incarnation-${crypto.randomUUID()}`; + const host = createAgentHost({ + socketPath, + createDriver: () => driver, + hostId: "agent-host-1", + hostGeneration: 1, + hostIncarnation, + supervisionKeyring: sig.keyring, + ...extra, + }); + resources.push({ host, dir }); + await host.start(); + return { host, socketPath, driver, hostIncarnation, ...sig }; +} +async function peer(path: string) { + return new Promise((ok) => { + const socket = connect(path), + messages: any[] = [], + d = new BoundedNdjsonDecoder(); + socket.on("data", (b) => messages.push(...d.push(Buffer.from(b)))); + socket.once("connect", () => ok({ socket, messages })); + }); +} +const send = (p: Peer, v: unknown) => p.socket.write(encodeNdjsonFrame(v)); +const wait = () => new Promise((r) => setTimeout(r, 15)); +async function attach( + p: Peer, + receipt: ReturnType["receipt"], + resume: null | { + lastHostSeq: number; + operations: { operationId: string; throughStreamSeq: number }[]; + } = null, +) { + send(p, { t: "hello", version: 5, requestId: "hello-1" }); + await wait(); + const h = p.messages.shift(); + send(p, { + t: "attach", + version: 5, + requestId: "attach-1", + fence, + planHash, + receipt: receipt({ + hostId: h.hostId, + hostGeneration: h.hostGeneration, + hostIncarnation: h.hostIncarnation, + hostChallenge: h.hostChallenge, + }), + resume, + }); + await wait(); + return p.messages.shift(); +} +function receipt( + state: AgentOperationReceiptV1["state"], +): AgentOperationReceiptV1 { + const terminalRef = { + appendId: "append-host-1", + entryIds: ["entry-output-host-1"], + firstSeq: 1, + lastSeq: 1, + throughChangeSeq: 1, + requestDigest: `sha256:${"9".repeat(64)}` as const, + }; + return { + version: 1, + operationId: "operation-1", + kind: "model", + fence, + planHash, + authorityHash: `sha256:${"c".repeat(64)}`, + descriptorDigest, + payloadDigest: `sha256:${"d".repeat(64)}`, + actorIdentity: { + supervisorEpoch: 1, + hostId: "agent-host-1", + hostGeneration: 1, + hostIncarnation: "incarnation-test", + transcriptAnchor: { + throughChangeSeq: 0, + entryIds: [], + digest: `sha256:${"e".repeat(64)}`, + }, + }, + state, + acceptedAtMs: now, + executingAtMs: state !== "prepared" ? now + 1 : undefined, + completedAtMs: state === "settled" ? now + 2 : undefined, + outcome: + state === "settled" ? { status: "succeeded", code: "ok" } : undefined, + transcriptRefs: state === "settled" ? [terminalRef] : undefined, + kernelTerminal: + state === "settled" + ? { + outputDigest: `sha256:${"f".repeat(64)}`, + outcomeCode: "ok", + transcriptRefs: [terminalRef], + pendingToolUseEntryIds: [], + } + : undefined, + providerRef: { adapterId: "test", adapterVersion: "1" }, + }; +} + +describe("Agent Host protocol v5", () => { + test("strict attach, operation receipts, stream credit and terminal drain", async () => { + const { socketPath, driver, receipt: sign } = await setup(); + const p = await peer(socketPath); + expect((await attach(p, sign)).mode).toBe("fresh"); + send(p, { + t: "start_turn", + version: 5, + requestId: "start-1", + planHash, + spec, + }); + await wait(); + expect(p.messages.shift().t).toBe("turn_started"); + await driver.transport!.requestOperation(spec.initialOperation); + await wait(); + const request = p.messages.find((x) => x.t === "operation_request"), + credit = p.messages.find((x) => x.t === "operation_stream_ack"); + expect(credit.creditBytes).toBe(256 * 1024); + send(p, { + t: "operation_receipt", + version: 5, + requestId: "r1", + fence, + ackHostSeq: request.hostSeq, + operationId: "operation-1", + receipt: receipt("executing"), + }); + send(p, { + t: "operation_stream", + version: 5, + requestId: "s1", + fence, + operationId: "operation-1", + streamSeq: 1, + encoding: "base64url+opensession-operation-v1", + bytes: Buffer.from("chunk").toString("base64url"), + }); + await wait(); + expect(driver.delivered).toEqual([1]); + expect( + p.messages.some( + (x) => x.t === "operation_stream_ack" && x.throughStreamSeq === 1, + ), + ).toBe(true); + const last = p.messages + .filter((x) => x.operationId === "operation-1") + .at(-1); + send(p, { + t: "operation_receipt", + version: 5, + requestId: "r2", + fence, + ackHostSeq: last.hostSeq, + operationId: "operation-1", + receipt: receipt("settled"), + }); + send(p, { + t: "consumption_ack", + version: 5, + requestId: "consumed-1", + fence, + ackHostSeq: last.hostSeq, + operations: [{ operationId: "operation-1", throughStreamSeq: 1 }], + }); + send(p, { + t: "consumption_ack", + version: 5, + requestId: "consumed-duplicate", + fence, + ackHostSeq: last.hostSeq, + operations: [{ operationId: "operation-1", throughStreamSeq: 1 }], + }); + driver.finish(); + await wait(); + expect(p.socket.destroyed).toBe(false); + expect(p.messages.filter((message) => message.t === "turn_terminal")).toHaveLength(1); + const terminal = p.messages.find((message) => message.t === "turn_terminal"); + expect(terminal).toMatchObject({ + hostGeneration: 1, + result: { status: "completed" }, + finalAckHostSeq: last.hostSeq, + operations: [{ operationId: "operation-1", throughStreamSeq: 1 }], + }); + send(p, { + t: "turn_terminal_ack", + version: 5, + requestId: "terminal-ack-1", + fence, + ackHostSeq: terminal.hostSeq, + resultDigest: terminal.resultDigest, + receiptsDigest: terminal.receiptsDigest, + }); + await wait(); + expect(p.socket.destroyed).toBe(true); + }); + test("replays one terminal after reconnect following the terminal write", async () => { + const { socketPath, driver, receipt: sign } = await setup({ reconnectGraceMs: 200 }); + const first = await peer(socketPath); + await attach(first, sign); + send(first, { t: "start_turn", version: 5, requestId: "start-replay", planHash, spec }); + await wait(); + first.messages.shift(); + await driver.transport!.requestOperation(spec.initialOperation); + await wait(); + const request = first.messages.find((message) => message.t === "operation_request"); + const finalIntent = first.messages.at(-1); + send(first, { t: "operation_receipt", version: 5, requestId: "executing-replay", fence, ackHostSeq: request.hostSeq, operationId: "operation-1", receipt: receipt("executing") }); + send(first, { t: "operation_receipt", version: 5, requestId: "settled-replay", fence, ackHostSeq: finalIntent.hostSeq, operationId: "operation-1", receipt: receipt("settled") }); + send(first, { t: "consumption_ack", version: 5, requestId: "consumed-replay", fence, ackHostSeq: finalIntent.hostSeq, operations: [{ operationId: "operation-1", throughStreamSeq: 0 }] }); + driver.finish(); + await wait(); + const written = first.messages.find((message) => message.t === "turn_terminal"); + expect(written).toBeDefined(); + first.socket.destroy(); + await wait(); + + const second = await peer(socketPath); + expect((await attach(second, sign, { lastHostSeq: finalIntent.hostSeq, operations: [{ operationId: "operation-1", throughStreamSeq: 0 }] })).mode).toBe("resumed"); + await wait(); + const replayed = second.messages.filter((message) => message.t === "turn_terminal"); + expect(replayed).toHaveLength(1); + expect(replayed[0]).toEqual(written); + send(second, { t: "turn_terminal_ack", version: 5, requestId: "terminal-replay-ack", fence, ackHostSeq: written.hostSeq, resultDigest: written.resultDigest, receiptsDigest: written.receiptsDigest }); + await wait(); + expect(second.socket.destroyed).toBe(true); + }); + + test("hydrates exact terminal replay before listener start", async () => { + const original = await setup({ reconnectGraceMs: 200 }); + const first = await peer(original.socketPath); + await attach(first, original.receipt); + send(first, { t: "start_turn", version: 5, requestId: "start-hydrate", planHash, spec }); + await wait(); + first.messages.shift(); + await original.driver.transport!.requestOperation(spec.initialOperation); + await wait(); + const request = first.messages.find((message) => message.t === "operation_request"); + const finalIntent = first.messages.at(-1); + send(first, { t: "operation_receipt", version: 5, requestId: "executing-hydrate", fence, ackHostSeq: request.hostSeq, operationId: "operation-1", receipt: receipt("executing") }); + send(first, { t: "operation_receipt", version: 5, requestId: "settled-hydrate", fence, ackHostSeq: finalIntent.hostSeq, operationId: "operation-1", receipt: receipt("settled") }); + send(first, { t: "consumption_ack", version: 5, requestId: "consumed-hydrate", fence, ackHostSeq: finalIntent.hostSeq, operations: [{ operationId: "operation-1", throughStreamSeq: 0 }] }); + original.driver.finish(); + await wait(); + const active = (original.host as any).active; + const terminal = first.messages.find((message) => message.t === "turn_terminal"); + expect(terminal).toBeDefined(); + const snapshot = { + spec, + planHash, + supervisorEpoch: active.authority.supervisorEpoch, + requestId: active.requestId, + hostSeq: active.seq, + acknowledgedHostSeq: active.acknowledgedHostSeq, + replay: active.replay.map((frame: any) => JSON.parse(frame.bytes.toString("utf8"))), + operations: [...active.ops.values()].map((operation: any) => ({ + request: operation.request, + receipt: operation.receipt, + sentHostSeqs: [...operation.sent], + throughStreamSeq: operation.through, + acknowledgedThroughStreamSeq: active.acknowledgedStreams.get(operation.request.operationId) ?? 0, + creditsBytes: operation.creditsBytes, + creditsChunks: operation.creditsChunks, + owedCreditBytes: operation.owedCreditBytes, + owedCreditChunks: operation.owedCreditChunks, + })), + result: active.result, + terminal: active.terminal, + }; + await original.host.stop(); + + const restoredDriver = new Driver(); + const restoredSocketPath = `${original.socketPath}.restored`; + const restored = createAgentHost({ + socketPath: restoredSocketPath, + createDriver: () => restoredDriver, + hostId: "agent-host-1", + hostGeneration: 1, + hostIncarnation: original.hostIncarnation, + supervisionKeyring: original.keyring, + reconnectGraceMs: 200, + }); + resources.unshift({ host: restored, dir: resources[0]!.dir }); + await expect( + restored.hydrateV5({ + ...snapshot, + terminal: { ...snapshot.terminal, hostIncarnation: "stale-incarnation" }, + }), + ).rejects.toThrow("Invalid hydrated Agent Host terminal"); + await restored.hydrateV5(snapshot); + await restored.start(); + await expect(restored.hydrateV5(snapshot)).rejects.toThrow( + "hydration must precede start", + ); + const second = await peer(restoredSocketPath); + expect((await attach(second, original.receipt, { lastHostSeq: terminal.hostSeq - 1, operations: [{ operationId: "operation-1", throughStreamSeq: 0 }] })).mode).toBe("resumed"); + await wait(); + const replayed = second.messages.filter((message) => message.t === "turn_terminal"); + expect(replayed).toEqual([terminal]); + send(second, { t: "turn_terminal_ack", version: 5, requestId: "hydrated-terminal-ack", fence, ackHostSeq: terminal.hostSeq, resultDigest: terminal.resultDigest, receiptsDigest: terminal.receiptsDigest }); + await wait(); + expect(second.socket.destroyed).toBe(true); + }); + + test("hydrates active operation state and recovers through the existing Driver factory", async () => { + const original = await setup({ reconnectGraceMs: 200 }); + const first = await peer(original.socketPath); + await attach(first, original.receipt); + send(first, { t: "start_turn", version: 5, requestId: "start-active-hydrate", planHash, spec }); + await wait(); + first.messages.shift(); + await original.driver.transport!.requestOperation(spec.initialOperation); + await wait(); + const request = first.messages.find((message) => message.t === "operation_request"); + const finalIntent = first.messages.at(-1); + send(first, { t: "operation_receipt", version: 5, requestId: "executing-active-hydrate", fence, ackHostSeq: request.hostSeq, operationId: "operation-1", receipt: receipt("executing") }); + send(first, { t: "consumption_ack", version: 5, requestId: "consumed-active-hydrate", fence, ackHostSeq: finalIntent.hostSeq, operations: [{ operationId: "operation-1", throughStreamSeq: 0 }] }); + await wait(); + const active = (original.host as any).active; + const snapshot = { + spec, + planHash, + supervisorEpoch: active.authority.supervisorEpoch, + requestId: active.requestId, + hostSeq: active.seq, + acknowledgedHostSeq: active.acknowledgedHostSeq, + replay: active.replay.map((frame: any) => JSON.parse(frame.bytes.toString("utf8"))), + operations: [...active.ops.values()].map((operation: any) => ({ + request: operation.request, + receipt: operation.receipt, + sentHostSeqs: [...operation.sent], + throughStreamSeq: operation.through, + acknowledgedThroughStreamSeq: active.acknowledgedStreams.get(operation.request.operationId) ?? 0, + creditsBytes: operation.creditsBytes, + creditsChunks: operation.creditsChunks, + owedCreditBytes: operation.owedCreditBytes, + owedCreditChunks: operation.owedCreditChunks, + })), + }; + await original.host.stop(); + + const restoredDriver = new Driver(true); + const restoredSocketPath = `${original.socketPath}.active-restored`; + const restored = createAgentHost({ + socketPath: restoredSocketPath, + createDriver: () => restoredDriver, + hostId: "agent-host-1", + hostGeneration: 1, + hostIncarnation: original.hostIncarnation, + supervisionKeyring: original.keyring, + reconnectGraceMs: 200, + }); + resources.unshift({ host: restored, dir: resources[0]!.dir }); + await restored.hydrateV5(snapshot); + expect(restoredDriver.transport).toBeDefined(); + expect((restored as any).active.ops.size).toBe(1); + expect((restored as any).active.replay.at(-1).seq).toBe(snapshot.hostSeq + 1); + await restored.start(); + const second = await peer(restoredSocketPath); + const attached = await attach(second, original.receipt, { lastHostSeq: snapshot.hostSeq, operations: [{ operationId: "operation-1", throughStreamSeq: 0 }] }); + expect(attached.mode).toBe("recovery_required"); + await wait(); + expect(second.messages.some((message) => message.t === "operation_query")).toBe(true); + }); + + test("keeps a detached driver alive through reconnect before terminal write and atomically resumes", async () => { + const { + socketPath, + driver, + receipt: sign, + } = await setup({ reconnectGraceMs: 80 }); + const first = await peer(socketPath); + await attach(first, sign); + send(first, { + t: "start_turn", + version: 5, + requestId: "start-1", + planHash, + spec, + }); + await wait(); + first.socket.destroy(); + await wait(); + expect(driver.cancelled).toBe(0); + const second = await peer(socketPath); + const a = await attach(second, sign, { lastHostSeq: 1, operations: [] }); + expect(a.mode).toBe("resumed"); + await new Promise((r) => setTimeout(r, 100)); + expect(driver.cancelled).toBe(0); + }); + test("consumes challenge before parsing and invokes canonical failpoints", async () => { + const seen: AgentHostFailpoint[] = []; + const { socketPath, receipt: sign } = await setup({ + failpoint: (p) => { + seen.push(p); + }, + }); + const p = await peer(socketPath); + await attach(p, sign); + expect(seen.slice(0, 3)).toEqual([ + "afterAttachChallengeConsumed", + "afterAttachVerifiedBeforeOwnerSwap", + "afterOwnerSwapBeforeAttachedWrite", + ]); + }); + test("rejects v4 without compatibility", async () => { + const { socketPath } = await setup(); + const p = await peer(socketPath); + send(p, { t: "hello", version: 4, requestId: "old" }); + await wait(); + expect(p.socket.destroyed).toBe(true); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/host.ts b/packages/core/opensession-server/src/agent-host/host.ts new file mode 100644 index 0000000000..307d3969ec --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/host.ts @@ -0,0 +1,1595 @@ +import { + chmod, + link, + lstat, + mkdir, + readFile, + rename, + unlink, + writeFile, +} from "node:fs/promises"; +import { dirname, isAbsolute, parse, resolve } from "node:path"; +import { connect, createServer, type Server, type Socket } from "node:net"; +import { createLinuxPeerCredentialVerifier } from "../server/security/transport/linux-peer-credentials"; +import { + createVerifiedUnixSocketServer, + type VerifiedUnixSocketServer, +} from "../server/security/transport/unix-socket-security"; +import { + AGENT_HOST_PROTOCOL_VERSION, + AGENT_HOST_SUPERVISION_AUDIENCE, + AGENT_HOST_SUPERVISION_PURPOSE, + INITIAL_AGENT_HOST_STREAM_BYTES, + INITIAL_AGENT_HOST_STREAM_CHUNKS, + MAX_AGENT_HOST_REPLAY_BYTES, + MAX_AGENT_HOST_REPLAY_FRAMES, + MAX_AGENT_HOST_STREAM_BYTES, + MAX_AGENT_HOST_STREAM_CHUNKS, + MAX_AGENT_HOST_WRITABLE_BYTES, + decodeAgentHostAttach, + decodeAgentHostConsumptionAck, + decodeAgentHostHello, + decodeAgentHostOperationCancel, + decodeAgentHostOperationCancelReceipt, + decodeAgentHostOperationQuery, + decodeAgentHostOperationQueryReceipt, + decodeAgentHostOperationReceipt, + decodeAgentHostOperationRequest, + decodeAgentHostOperationStream, + decodeAgentHostOperationStreamAck, + decodeAgentHostStartTurn, + decodeAgentHostTurnStarted, + decodeAgentHostTurnTerminal, + decodeAgentHostTurnTerminalAck, + decodeAgentOperationReceiptV1, + decodeAgentTurnSpec, + decodeAgentHostSupervisionPublicKeyringV2, + decodeExecutorId, + hashAgentTurnSpecV2, + hashAgentTurnResultV1, + hashAgentTurnTerminalReceiptsV1, + projectAgentTurnTerminalOperationsV1, + verifySignedAgentHostSupervisionEnvelopeV2, + type AgentHostAttachResumeCursorV4, + type AgentHostClientMessage, + type AgentHostInitialOperationV4, + type AgentHostOperationCancelV4, + type AgentHostServerMessage, + type AgentHostSupervisionPublicKeyringV2, + type AgentHostTurnTerminalV5, + type AgentOperationReceiptV1, + type AgentTurnFence, + type AgentTurnSpec, +} from "@tellahq/opensession-protocol"; +import type { + AgentHostOperationCancel, + AgentHostOperationQuery, + AgentHostOperationRequest, + AgentHostOperationTransport, + AgentTurnDriver, + AgentTurnDriverFactory, + AgentTurnResult, +} from "./driver"; +import { + AGENT_HOST_MAX_FRAME_BYTES, + BoundedNdjsonDecoder, + encodeNdjsonFrame, +} from "./socket-framing"; + +export type AgentHostFailpoint = + | "afterAttachChallengeConsumed" + | "afterAttachVerifiedBeforeOwnerSwap" + | "afterOwnerSwapBeforeAttachedWrite" + | "afterOperationIntentBufferedBeforeWrite" + | "afterStreamAcceptedBeforeDriverDelivery" + | "afterDriverDeliveryBeforeStreamAck" + | "onReconnectDeadline"; +export interface AgentHostOptions { + /** Legacy test/development listener. Production must use inheritedFd. */ + socketPath?: string; + /** Already-listening AF_UNIX descriptor supplied by systemd socket activation. */ + inheritedFd?: number; + /** Exact non-root gateway UID accepted through SO_PEERCRED. */ + expectedPeerUid?: number; + createDriver: AgentTurnDriverFactory; + readonly hostId: string; + readonly hostGeneration: number; + readonly hostIncarnation: string; + readonly supervisionKeyring: AgentHostSupervisionPublicKeyringV2; + maxFrameBytes?: number; + cancellationDeadlineMs?: number; + livenessProbeTimeoutMs?: number; + attachDeadlineMs?: number; + reconnectGraceMs?: number; + now?: () => number; + setTimeout?: typeof globalThis.setTimeout; + clearTimeout?: typeof globalThis.clearTimeout; + failpoint?: (point: AgentHostFailpoint) => void | Promise; +} +export type AgentHostReplayMessageV5 = Extract< + AgentHostServerMessage, + { readonly hostSeq: number } +>; +export interface AgentHostHydratedOperationV5 { + readonly request: Readonly; + readonly receipt?: Readonly; + readonly sentHostSeqs: readonly number[]; + readonly throughStreamSeq: number; + readonly acknowledgedThroughStreamSeq: number; + readonly creditsBytes: number; + readonly creditsChunks: number; + readonly owedCreditBytes: number; + readonly owedCreditChunks: number; +} +export interface AgentHostHydratedTurnV5 { + readonly spec: Readonly; + readonly planHash: string; + readonly supervisorEpoch: number; + readonly requestId: string; + readonly hostSeq: number; + readonly acknowledgedHostSeq: number; + readonly replay: readonly Readonly[]; + readonly operations: readonly Readonly[]; + readonly result?: Readonly; + readonly terminal?: Readonly; +} +type Timer = ReturnType; +interface Authority { + fence: Readonly; + planHash: string; + supervisorEpoch: number; + envelope: unknown; +} +interface Peer { + socket: Socket; + hello: boolean; + challenge?: string; + attached?: Authority; + closed: boolean; + timer?: Timer; + reads: Promise; + writes: Promise; + queuedBytes: number; +} +interface Op { + request: Readonly; + receipt?: AgentOperationReceiptV1; + receiptJson?: string; + sent: Set; + through: number; + pending: number; + creditsBytes: number; + creditsChunks: number; + terminal: boolean; + delivery: Promise; + owedCreditBytes: number; + owedCreditChunks: number; + timer?: Timer; +} +interface Frame { + seq: number; + bytes: Buffer; +} +interface Turn { + fence: Readonly; + spec: AgentTurnSpec; + driver: AgentTurnDriver; + owner?: Peer; + authority: Authority; + requestId: string; + ops: Map; + seq: number; + replay: Frame[]; + replayBytes: number; + reconnect?: Timer; + deadline?: Timer; + runSettled: boolean; + result?: AgentTurnResult; + cancelling: boolean; + cancelSettled: boolean; + acknowledgedHostSeq: number; + acknowledgedStreams: Map; + terminal?: AgentHostTurnTerminalV5; + completing: boolean; +} +interface Identity { + dev: number; + ino: number; +} +const rec = (v: unknown): v is Record => + !!v && typeof v === "object" && !Array.isArray(v); +const id = (v: unknown): v is string => typeof v === "string" && v.length > 0; +const sameFence = (a: AgentTurnFence, b: AgentTurnFence) => + a.sessionId === b.sessionId && + a.runId === b.runId && + a.turnId === b.turnId && + a.generation === b.generation; +const terminal = (s: AgentOperationReceiptV1["state"]) => + s === "settled" || s === "indeterminate"; +const rank = (s: AgentOperationReceiptV1["state"]) => + s === "prepared" ? 0 : s === "executing" ? 1 : 2; + +export class AgentHost { + private server?: Server; + private inheritedServer?: VerifiedUnixSocketServer; + private peerVerifier?: Awaited< + ReturnType + >; + private starting?: Promise; + private stopping?: Promise; + private active?: Turn; + private attaching?: Peer; + private owner?: Peer; + private poisoned = false; + private socketIdentity?: Identity; + private claimIdentity?: Identity; + private claimNonce?: string; + private peers = new Set(); + private epochs = new Map(); + private generations = new Map(); + private keyring: AgentHostSupervisionPublicKeyringV2; + constructor(private options: AgentHostOptions) { + const ring = decodeAgentHostSupervisionPublicKeyringV2( + options.supervisionKeyring, + ); + if ( + !decodeExecutorId(options.hostId) || + !Number.isSafeInteger(options.hostGeneration) || + options.hostGeneration < 1 || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{7,255}$/.test(options.hostIncarnation) || + !ring + ) + throw new Error("Invalid Agent Host v5 identity or public keyring"); + this.keyring = ring; + } + start() { + if (this.server?.listening) return Promise.resolve(); + if (this.starting) return this.starting; + this.starting = this.listen().finally(() => { + this.starting = undefined; + }); + return this.starting; + } + stop() { + if (this.stopping) return this.stopping; + this.stopping = this.stopInner().finally(() => { + this.stopping = undefined; + }); + return this.stopping; + } + /** Restores one exact v5 turn before the inherited listener starts. The + * caller must derive this snapshot from authenticated durable Host state. */ + async hydrateV5(snapshot: Readonly): Promise { + if (this.active || this.starting || this.server || this.inheritedServer) + throw new Error("Agent Host hydration must precede start"); + const admissionNow = Math.min( + this.now(), + snapshot.spec.initialOperation.deadlineMs - 1, + snapshot.spec.limits.turnDeadlineMs - 1, + ); + const spec = decodeAgentTurnSpec(snapshot.spec, admissionNow); + if (!spec || (await hashAgentTurnSpecV2(spec, admissionNow)) !== snapshot.planHash) + throw new Error("Invalid hydrated Agent Host turn plan"); + if ( + !id(snapshot.requestId) || + !Number.isSafeInteger(snapshot.supervisorEpoch) || + snapshot.supervisorEpoch < 1 || + !Number.isSafeInteger(snapshot.hostSeq) || + snapshot.hostSeq < 1 || + !Number.isSafeInteger(snapshot.acknowledgedHostSeq) || + snapshot.acknowledgedHostSeq < 0 || + snapshot.acknowledgedHostSeq > snapshot.hostSeq || + !Array.isArray(snapshot.replay) || + !Array.isArray(snapshot.operations) || + snapshot.operations.length > spec.limits.maxInFlightOperations || + (!!snapshot.terminal !== !!snapshot.result) + ) + throw new Error("Invalid hydrated Agent Host turn state"); + const replay: Frame[] = []; + let replayBytes = 0; + let previousSeq = 0; + for (const raw of snapshot.replay) { + const message = await this.decodeReplayMessage(raw, spec); + if ( + !message || + message.requestId !== snapshot.requestId || + !sameFence(message.fence, spec.fence) || + message.hostSeq <= previousSeq || + message.hostSeq > snapshot.hostSeq + ) + throw new Error("Invalid hydrated Agent Host replay"); + const bytes = encodeNdjsonFrame(message, this.options.maxFrameBytes); + replay.push({ seq: message.hostSeq, bytes }); + replayBytes += bytes.length; + previousSeq = message.hostSeq; + } + if ( + replay.length > MAX_AGENT_HOST_REPLAY_FRAMES || + replayBytes > MAX_AGENT_HOST_REPLAY_BYTES + ) + throw new Error("Hydrated Agent Host replay exceeds bounds"); + const operations = new Map(); + const acknowledgedStreams = new Map(); + for (const hydrated of snapshot.operations) { + const request = hydrated.request; + if ( + !request || + operations.has(request.operationId) || + request.deadlineMs > spec.limits.turnDeadlineMs || + !Number.isSafeInteger(hydrated.throughStreamSeq) || + hydrated.throughStreamSeq < 0 || + !Number.isSafeInteger(hydrated.acknowledgedThroughStreamSeq) || + hydrated.acknowledgedThroughStreamSeq < 0 || + hydrated.acknowledgedThroughStreamSeq > hydrated.throughStreamSeq || + !Number.isSafeInteger(hydrated.creditsBytes) || + hydrated.creditsBytes < 0 || + hydrated.creditsBytes > spec.limits.maxBufferedStreamBytes || + !Number.isSafeInteger(hydrated.creditsChunks) || + hydrated.creditsChunks < 0 || + hydrated.creditsChunks > spec.limits.maxBufferedStreamChunks || + !Number.isSafeInteger(hydrated.owedCreditBytes) || + hydrated.owedCreditBytes < 0 || + hydrated.owedCreditBytes > spec.limits.maxBufferedStreamBytes || + !Number.isSafeInteger(hydrated.owedCreditChunks) || + hydrated.owedCreditChunks < 0 || + hydrated.owedCreditChunks > spec.limits.maxBufferedStreamChunks || + !Array.isArray(hydrated.sentHostSeqs) + ) + throw new Error("Invalid hydrated Agent Host operation"); + const descriptor = await decodeAgentHostOperationRequest( + { + t: "operation_request", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: snapshot.requestId, + fence: spec.fence, + hostSeq: 1, + operationId: request.operationId, + descriptor: request.descriptor, + descriptorDigest: request.descriptorDigest, + deadlineMs: request.deadlineMs, + }, + Math.min(this.now(), request.deadlineMs - 1), + spec.limits.turnDeadlineMs, + ); + if (!descriptor) throw new Error("Invalid hydrated Agent Host operation descriptor"); + const sent = new Set(); + for (const seq of hydrated.sentHostSeqs) { + if (!Number.isSafeInteger(seq) || seq < 1 || seq > snapshot.hostSeq || sent.has(seq)) + throw new Error("Invalid hydrated Agent Host sent cursor"); + sent.add(seq); + } + const receipt = hydrated.receipt + ? decodeAgentOperationReceiptV1(hydrated.receipt) + : undefined; + if ( + hydrated.receipt && + (!receipt || + receipt.operationId !== request.operationId || + receipt.descriptorDigest !== request.descriptorDigest || + !sameFence(receipt.fence, spec.fence)) + ) + throw new Error("Invalid hydrated Agent Host receipt"); + const operation: Op = { + request: Object.freeze({ ...request, descriptor: descriptor.descriptor }), + ...(receipt ? { receipt, receiptJson: JSON.stringify(receipt) } : {}), + sent, + through: hydrated.throughStreamSeq, + pending: 0, + creditsBytes: hydrated.creditsBytes, + creditsChunks: hydrated.creditsChunks, + terminal: !!receipt && terminal(receipt.state), + delivery: Promise.resolve(), + owedCreditBytes: hydrated.owedCreditBytes, + owedCreditChunks: hydrated.owedCreditChunks, + }; + operations.set(request.operationId, operation); + acknowledgedStreams.set( + request.operationId, + hydrated.acknowledgedThroughStreamSeq, + ); + } + for (const raw of snapshot.replay) { + if (raw.t === "turn_started" || raw.t === "turn_terminal") continue; + const operation = operations.get(raw.operationId); + if (!operation) throw new Error("Hydrated replay references an unknown operation"); + if ( + raw.t === "operation_request" && + (raw.descriptorDigest !== operation.request.descriptorDigest || + raw.deadlineMs !== operation.request.deadlineMs || + JSON.stringify(raw.descriptor) !== JSON.stringify(operation.request.descriptor)) + ) + throw new Error("Hydrated replay operation identity changed"); + if ( + raw.t === "operation_query" && + (!operation.receipt || + raw.descriptorDigest !== operation.request.descriptorDigest || + raw.payloadDigest !== operation.receipt.payloadDigest) + ) + throw new Error("Hydrated replay query identity changed"); + if ( + raw.t === "operation_stream_ack" && + raw.throughStreamSeq > operation.through + ) + throw new Error("Hydrated replay stream cursor is ahead"); + } + const driver = this.options.createDriver(spec); + const authority: Authority = { + fence: spec.fence, + planHash: snapshot.planHash, + supervisorEpoch: snapshot.supervisorEpoch, + envelope: null, + }; + const turn: Turn = { + fence: spec.fence, + spec, + driver, + authority, + requestId: snapshot.requestId, + ops: operations, + seq: snapshot.hostSeq, + replay, + replayBytes, + runSettled: !!snapshot.result, + ...(snapshot.result ? { result: structuredClone(snapshot.result) } : {}), + cancelling: false, + cancelSettled: false, + acknowledgedHostSeq: snapshot.acknowledgedHostSeq, + acknowledgedStreams, + ...(snapshot.terminal ? { terminal: structuredClone(snapshot.terminal) } : {}), + completing: false, + }; + if (snapshot.terminal) { + const terminalMessage = decodeAgentHostTurnTerminal(snapshot.terminal); + const projectedOperations = await projectAgentTurnTerminalOperationsV1( + [...operations].map(([operationId, operation]) => { + if (!operation.receipt || !operation.terminal) + throw new Error("Hydrated terminal has a nonterminal operation"); + return { + operationId, + receipt: operation.receipt, + throughStreamSeq: operation.through, + }; + }), + ); + if ( + !terminalMessage || + !sameFence(terminalMessage.fence, spec.fence) || + terminalMessage.hostSeq !== snapshot.hostSeq || + terminalMessage.hostGeneration !== this.options.hostGeneration || + terminalMessage.hostIncarnation !== this.options.hostIncarnation || + terminalMessage.finalAckHostSeq !== snapshot.acknowledgedHostSeq || + terminalMessage.result.status !== snapshot.result!.status || + (await hashAgentTurnResultV1(snapshot.result!)) !== terminalMessage.resultDigest || + (await hashAgentTurnTerminalReceiptsV1(projectedOperations)) !== terminalMessage.receiptsDigest || + JSON.stringify(projectedOperations) !== JSON.stringify(terminalMessage.operations) || + !snapshot.replay.some( + (message) => + message.t === "turn_terminal" && + JSON.stringify(message) === JSON.stringify(terminalMessage), + ) + ) + throw new Error("Invalid hydrated Agent Host terminal"); + } + this.active = turn; + this.epochs.set(spec.fence.sessionId, snapshot.supervisorEpoch); + this.generations.set(spec.fence.sessionId, spec.fence.generation); + if (!snapshot.terminal) { + turn.deadline = this.set( + () => this.cancelTurn(turn, "turn_deadline"), + Math.max(0, spec.limits.turnDeadlineMs - this.now()), + ); + for (const operation of operations.values()) + if (!operation.terminal) + operation.timer = this.set( + () => void this.cancelOp(turn, { + operationId: operation.request.operationId, + cancelId: `deadline-${crypto.randomUUID()}`, + reason: "turn_deadline", + }).catch(() => {}), + Math.max(0, operation.request.deadlineMs - this.now()), + ); + this.runDriver(turn); + } + } + private async decodeReplayMessage( + value: unknown, + spec: AgentTurnSpec, + ): Promise { + if (!rec(value) || typeof value.t !== "string") return undefined; + const decoded = + decodeAgentHostTurnStarted(value) ?? + decodeAgentHostTurnTerminal(value) ?? + (await decodeAgentHostOperationRequest( + value, + Math.min(this.now(), spec.limits.turnDeadlineMs - 1), + spec.limits.turnDeadlineMs, + )) ?? + decodeAgentHostOperationQuery(value) ?? + decodeAgentHostOperationCancel(value) ?? + decodeAgentHostOperationStreamAck(value); + return decoded as AgentHostReplayMessageV5 | undefined; + } + private now() { + return (this.options.now ?? Date.now)(); + } + private duration(v: number | undefined, fallback: number, name: string) { + const n = v ?? fallback; + if (!Number.isFinite(n) || n <= 0) + throw new Error(`${name} must be positive`); + return n; + } + private set(fn: () => void, ms: number) { + const t = (this.options.setTimeout ?? setTimeout)(fn, ms); + t.unref?.(); + return t; + } + private clear(t?: Timer) { + if (t) (this.options.clearTimeout ?? clearTimeout)(t); + } + private async hit(point: AgentHostFailpoint) { + await this.options.failpoint?.(point); + } + + private async listen() { + if (this.poisoned) + throw new Error("Agent Host requires process replacement"); + if (this.options.inheritedFd !== undefined) { + if (this.options.socketPath !== undefined) + throw new Error( + "Agent Host inherited listener cannot name a socket path", + ); + const expectedPeerUid = this.options.expectedPeerUid; + if ( + !Number.isSafeInteger(expectedPeerUid) || + expectedPeerUid! <= 0 || + expectedPeerUid! > 0xffff_ffff + ) + throw new Error( + "Agent Host inherited listener requires an exact non-root gateway UID", + ); + const verifier = await createLinuxPeerCredentialVerifier(); + this.peerVerifier = verifier; + const inherited = createVerifiedUnixSocketServer( + verifier, + { uid: expectedPeerUid! }, + (accepted) => { + accepted.assertCurrent(); + this.accept(accepted.socket); + accepted.socket.resume(); + }, + () => {}, + { listenerMode: "inherited-fd-only" }, + ); + this.inheritedServer = inherited; + try { + await inherited.listen({ inheritedFd: this.options.inheritedFd }); + return; + } catch (error) { + verifier.close(); + this.peerVerifier = undefined; + this.inheritedServer = undefined; + throw error; + } + } + if (!this.options.socketPath) + throw new Error("Agent Host listener is unavailable"); + await this.prepareParent(); + try { + await this.claim(); + await this.removeStale(); + const server = createServer((s) => this.accept(s)); + this.server = server; + await new Promise((ok, fail) => { + server.once("error", fail); + server.listen(this.options.socketPath, ok); + }); + const st = await lstat(this.options.socketPath); + if (!st.isSocket() || st.isSymbolicLink()) + throw new Error("unsafe Agent Host socket"); + await chmod(this.options.socketPath, 0o600); + this.socketIdentity = { dev: st.dev, ino: st.ino }; + } catch (e) { + await this.unlinkSocket(); + await this.releaseClaim(); + throw e; + } + } + private async stopInner() { + await this.starting?.catch(() => {}); + const server = this.server; + const inheritedServer = this.inheritedServer; + this.server = undefined; + this.inheritedServer = undefined; + const active = this.active; + if (active) this.poisoned = true; + for (const p of this.peers) { + p.closed = true; + p.socket.destroy(); + } + this.peers.clear(); + if (server?.listening) + await new Promise((ok) => server.close(() => ok())); + if (inheritedServer) await inheritedServer.closeAndDrain(5_000); + if (active) { + await Promise.allSettled([ + Promise.resolve().then(() => active.driver.cancel()), + Promise.resolve().then(() => active.driver.shutdown()), + ]); + } + this.peerVerifier?.close(); + this.peerVerifier = undefined; + if (this.options.socketPath) { + await this.unlinkSocket(); + if (!this.active) await this.releaseClaim(); + } + } + private async prepareParent() { + const path = this.options.socketPath!; + if (!isAbsolute(path) || resolve(path) !== path) + throw new Error("Agent Host socket path must be absolute and normalized"); + const parent = dirname(path), + root = parse(parent).root; + if (parent === root) throw new Error("invalid socket parent"); + let current = root; + for (const part of parent.slice(root.length).split("/").filter(Boolean)) { + current = resolve(current, part); + try { + const st = await lstat(current); + if (!st.isDirectory() || st.isSymbolicLink()) + throw new Error("unsafe socket parent"); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + await mkdir(current, { mode: 0o700 }); + } + } + const st = await lstat(parent), + uid = process.getuid?.(); + if (uid !== undefined && st.uid !== uid) + throw new Error("socket parent owner mismatch"); + await chmod(parent, 0o700); + } + private get claimPath() { + return `${this.options.socketPath!}.claim`; + } + private async claim() { + const nonce = crypto.randomUUID(), + tmp = `${this.claimPath}.tmp-${nonce}`; + await writeFile(tmp, JSON.stringify({ pid: process.pid, nonce }), { + flag: "wx", + mode: 0o400, + }); + const st = await lstat(tmp); + try { + await link(tmp, this.claimPath); + this.claimNonce = nonce; + this.claimIdentity = { dev: st.dev, ino: st.ino }; + await this.verifyClaim(this.claimPath); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") + throw Object.assign(new Error("Agent Host socket is already claimed"), { + code: "EADDRINUSE", + }); + throw e; + } finally { + await unlink(tmp).catch(() => {}); + } + } + private async verifyClaim(path: string) { + const st = await lstat(path), + data = JSON.parse(await readFile(path, "utf8")); + const i = this.claimIdentity; + if ( + !i || + !st.isFile() || + st.isSymbolicLink() || + st.dev !== i.dev || + st.ino !== i.ino || + data.nonce !== this.claimNonce + ) { + this.poisoned = true; + throw new Error("Agent Host claim ownership changed"); + } + } + private async removeStale() { + const path = this.options.socketPath!; + try { + const st = await lstat(path); + if (!st.isSocket() || st.isSymbolicLink()) + throw new Error("unsafe socket"); + if (await this.probe()) + throw Object.assign(new Error("Agent Host socket is already live"), { + code: "EADDRINUSE", + }); + const old = `${path}.stale-${crypto.randomUUID()}`; + await rename(path, old); + await unlink(old); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + } + } + private probe() { + const path = this.options.socketPath!; + return new Promise((ok, fail) => { + const s = connect(path); + let done = false; + const finish = (v: boolean, e?: Error) => { + if (done) return; + done = true; + this.clear(timer); + s.destroy(); + e ? fail(e) : ok(v); + }; + const timer = this.set( + () => finish(false, new Error("liveness probe timed out")), + this.duration( + this.options.livenessProbeTimeoutMs, + 250, + "livenessProbeTimeoutMs", + ), + ); + s.once("connect", () => finish(true)); + s.once("error", (e: NodeJS.ErrnoException) => + e.code === "ENOENT" || e.code === "ECONNREFUSED" + ? finish(false) + : finish(false, e), + ); + }); + } + private async unlinkSocket() { + const path = this.options.socketPath!; + const i = this.socketIdentity; + this.socketIdentity = undefined; + if (!i) return; + try { + const st = await lstat(path); + if ( + st.isSocket() && + !st.isSymbolicLink() && + st.dev === i.dev && + st.ino === i.ino + ) { + const q = `${path}.cleanup-${crypto.randomUUID()}`; + await rename(path, q); + await unlink(q); + } + } catch {} + } + private async releaseClaim() { + if (!this.claimNonce || !this.claimIdentity) return; + await this.verifyClaim(this.claimPath); + const q = `${this.claimPath}.release-${this.claimNonce}`; + await rename(this.claimPath, q); + await this.verifyClaim(q); + await unlink(q); + this.claimNonce = undefined; + this.claimIdentity = undefined; + } + + private accept(socket: Socket) { + const p: Peer = { + socket, + hello: false, + closed: false, + reads: Promise.resolve(), + writes: Promise.resolve(), + queuedBytes: 0, + }; + this.peers.add(p); + const decoder = new BoundedNdjsonDecoder( + this.options.maxFrameBytes ?? AGENT_HOST_MAX_FRAME_BYTES, + ); + p.timer = this.set( + () => this.close(p), + this.duration(this.options.attachDeadlineMs, 5_000, "attachDeadlineMs"), + ); + socket.on("data", (b) => { + try { + for (const v of decoder.push(Buffer.from(b))) + p.reads = p.reads + .then(() => this.receive(p, v)) + .catch(() => this.close(p)); + } catch { + this.close(p); + } + }); + socket.on("end", () => { + try { + decoder.finish(); + } catch { + this.close(p); + } + }); + socket.on("error", () => this.close(p)); + socket.on("close", () => this.disconnected(p)); + } + private async receive(p: Peer, raw: unknown) { + if (p.closed) return; + if (!p.hello) { + const hello = decodeAgentHostHello(raw); + if (!hello) { + if ( + rec(raw) && + id(raw.requestId) && + raw.version !== AGENT_HOST_PROTOCOL_VERSION + ) + this.send(p, { + t: "error", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: raw.requestId, + code: "unsupported_version", + message: "Unsupported Agent Host protocol version", + }); + this.close(p); + return; + } + p.hello = true; + p.challenge = crypto.randomUUID(); + this.send(p, { + ...hello, + accepted: true, + hostId: this.options.hostId, + hostGeneration: this.options.hostGeneration, + hostIncarnation: this.options.hostIncarnation, + hostChallenge: p.challenge, + }); + return; + } + if (!p.attached) { + await this.attach(p, raw); + return; + } + const m = + decodeAgentHostStartTurn(raw, this.now()) ?? + decodeAgentHostOperationReceipt(raw) ?? + decodeAgentHostOperationQueryReceipt(raw) ?? + decodeAgentHostOperationCancelReceipt(raw) ?? + decodeAgentHostOperationStream(raw) ?? + decodeAgentHostConsumptionAck(raw) ?? + decodeAgentHostTurnTerminalAck(raw); + if (!m) return this.invalid(p, raw); + if (m.t === "start_turn") return this.startTurn(p, m); + const turn = this.active; + if (!turn || turn.owner !== p || !sameFence(turn.fence, m.fence)) + return this.close(p); + if (m.t === "consumption_ack") return this.consumptionAck(turn, m); + if (m.t === "turn_terminal_ack") return this.terminalAck(turn, m); + await this.operationMessage(turn, m); + } + private invalid(p: Peer, raw: unknown) { + this.send(p, { + t: "error", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: rec(raw) && id(raw.requestId) ? raw.requestId : "invalid", + code: "invalid_request", + message: "Invalid Agent Host request", + }); + this.close(p); + } + private async attach(p: Peer, raw: unknown) { + const challenge = p.challenge; + p.challenge = undefined; + await this.hit("afterAttachChallengeConsumed"); + const m = decodeAgentHostAttach(raw); + if (!challenge || !m || this.attaching || this.poisoned) + return this.close(p); + const e = m.receipt.expected; + if ( + !sameFence(e.fence, m.fence) || + e.planHash !== m.planHash || + e.hostId !== this.options.hostId || + e.hostGeneration !== this.options.hostGeneration || + e.hostIncarnation !== this.options.hostIncarnation || + e.hostChallenge !== challenge || + e.audience !== AGENT_HOST_SUPERVISION_AUDIENCE || + e.purpose !== AGENT_HOST_SUPERVISION_PURPOSE + ) + return this.close(p); + this.attaching = p; + const a = await verifySignedAgentHostSupervisionEnvelopeV2( + m.receipt.envelope, + this.keyring, + e, + this.now(), + ); + await this.hit("afterAttachVerifiedBeforeOwnerSwap"); + if (!a || p.closed || this.attaching !== p) return this.close(p); + const turn = this.active, + resumed = + !!turn && + sameFence(turn.fence, a.fence) && + turn.authority.planHash === a.planHash; + const oldEpoch = this.epochs.get(a.fence.sessionId) ?? 0, + oldGen = this.generations.get(a.fence.sessionId) ?? 0; + if ( + a.supervisorEpoch <= oldEpoch || + a.fence.generation < oldGen || + (turn && !resumed) || + (resumed && m.resume === null) || + (!turn && m.resume !== null) + ) { + this.attaching = undefined; + return this.close(p); + } + const authority: Authority = { + fence: Object.freeze({ ...a.fence }), + planHash: a.planHash, + supervisorEpoch: a.supervisorEpoch, + envelope: m.receipt.envelope, + }; + const old = resumed ? turn.owner : this.owner; + p.attached = authority; + this.owner = p; + if (resumed) turn.owner = p; + this.epochs.set(a.fence.sessionId, a.supervisorEpoch); + this.generations.set(a.fence.sessionId, a.fence.generation); + this.attaching = undefined; + this.clear(p.timer); + p.timer = undefined; + if (resumed) { + this.clear(turn.reconnect); + turn.reconnect = undefined; + if (m.resume!.lastHostSeq <= turn.seq) + turn.acknowledgedHostSeq = Math.max( + turn.acknowledgedHostSeq, + m.resume!.lastHostSeq, + ); + for (const cursor of m.resume!.operations) { + const operation = turn.ops.get(cursor.operationId); + if (operation && cursor.throughStreamSeq <= operation.through) + turn.acknowledgedStreams.set( + cursor.operationId, + Math.max( + turn.acknowledgedStreams.get(cursor.operationId) ?? 0, + cursor.throughStreamSeq, + ), + ); + } + } + if (old && old !== p) this.close(old); + await this.hit("afterOwnerSwapBeforeAttachedWrite"); + const recovery = resumed && this.needsRecovery(turn, m.resume!); + this.send(p, { + t: "attached", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: m.requestId, + fence: authority.fence, + planHash: authority.planHash as `sha256:${string}`, + supervisorEpoch: authority.supervisorEpoch, + mode: resumed ? (recovery ? "recovery_required" : "resumed") : "fresh", + replayFromHostSeq: resumed + ? recovery + ? turn.seq + 1 + : m.resume!.lastHostSeq + 1 + : 0, + }); + if (resumed) { + if (recovery) await this.recover(turn, m.resume!); + else + for (const f of turn.replay) + if (f.seq > m.resume!.lastHostSeq) this.sendBytes(p, f.bytes); + } else + p.timer = this.set( + () => { + if (this.owner === p && !this.active) this.close(p); + }, + this.duration(this.options.attachDeadlineMs, 5000, "attachDeadlineMs"), + ); + } + private async startTurn( + p: Peer, + m: Extract, + ) { + const a = p.attached; + if ( + !a || + this.owner !== p || + !sameFence(a.fence, m.spec.fence) || + a.planHash !== m.planHash + ) + return this.close(p); + let hash; + try { + hash = await hashAgentTurnSpecV2(m.spec, this.now()); + } catch { + return this.close(p); + } + if (hash !== a.planHash) return this.close(p); + if (this.active) return this.invalid(p, m); + this.clear(p.timer); + p.timer = undefined; + let driver; + try { + driver = this.options.createDriver(m.spec); + } catch (e) { + this.send(p, { + t: "error", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: m.requestId, + code: "turn_failed", + message: String(e), + fence: m.spec.fence, + }); + return; + } + const t: Turn = { + fence: m.spec.fence, + spec: m.spec, + driver, + owner: p, + authority: a, + requestId: m.requestId, + ops: new Map(), + seq: 0, + replay: [], + replayBytes: 0, + runSettled: false, + cancelling: false, + cancelSettled: false, + acknowledgedHostSeq: 0, + acknowledgedStreams: new Map(), + completing: false, + }; + this.active = t; + t.deadline = this.set( + () => this.cancelTurn(t, "turn_deadline"), + Math.max(0, m.spec.limits.turnDeadlineMs - this.now()), + ); + this.sequenced(t, { + t: "turn_started", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: m.requestId, + fence: t.fence, + } as never); + this.runDriver(t); + } + private runDriver(t: Turn) { + const transport: AgentHostOperationTransport = { + requestOperation: (request) => this.requestOp(t, request), + queryOperation: (query) => this.queryOp(t, query), + cancelOperation: (cancel) => this.cancelOp(t, cancel), + }; + let run: Promise; + try { + run = Promise.resolve(t.driver.run(t.spec, transport)); + } catch (error) { + run = Promise.reject(error); + } + void run.then( + (result) => { + t.runSettled = true; + t.result = result; + this.complete(t); + }, + (error) => { + t.runSettled = true; + t.result = { status: "failed", error: String(error) }; + this.complete(t); + }, + ); + } + private async requestOp(t: Turn, r: AgentHostOperationRequest) { + if (this.active !== t || t.cancelling) throw Error("turn unavailable"); + const existing = t.ops.get(r.operationId); + if (existing) { + if ( + existing.request.descriptorDigest !== r.descriptorDigest || + existing.request.deadlineMs !== r.deadlineMs || + JSON.stringify(existing.request.descriptor) !== JSON.stringify(r.descriptor) + ) + throw Error("recovered operation identity changed"); + if (existing.receipt) + await this.queryOp(t, { + operationId: r.operationId, + kind: r.descriptor.kind, + descriptorDigest: r.descriptorDigest, + payloadDigest: existing.receipt.payloadDigest, + afterStreamSeq: existing.through, + }); + else { + existing.sent.add( + this.sequenced(t, { + t: "operation_request", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: t.requestId, + fence: t.fence, + operationId: r.operationId, + descriptor: r.descriptor, + descriptorDigest: r.descriptorDigest, + deadlineMs: r.deadlineMs, + } as never), + ); + } + return; + } + if (t.ops.size >= Math.min(8, t.spec.limits.maxInFlightOperations)) + throw Error("operation limit"); + if ( + r.deadlineMs <= this.now() || + r.deadlineMs > t.spec.limits.turnDeadlineMs + ) + throw Error("invalid deadline"); + const o: Op = { + request: r, + sent: new Set(), + through: 0, + pending: 0, + creditsBytes: 0, + creditsChunks: 0, + terminal: false, + delivery: Promise.resolve(), + owedCreditBytes: 0, + owedCreditChunks: 0, + }; + t.ops.set(r.operationId, o); + const seq = this.buffer(t, { + t: "operation_request", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: t.requestId, + fence: t.fence, + operationId: r.operationId, + descriptor: r.descriptor, + descriptorDigest: r.descriptorDigest, + deadlineMs: r.deadlineMs, + } as never); + o.sent.add(seq); + await this.hit("afterOperationIntentBufferedBeforeWrite"); + this.writeBuffered(t, seq); + this.credit( + t, + o, + 0, + INITIAL_AGENT_HOST_STREAM_BYTES, + INITIAL_AGENT_HOST_STREAM_CHUNKS, + ); + o.timer = this.set( + () => { + void this.cancelOp(t, { + operationId: r.operationId, + cancelId: `deadline-${crypto.randomUUID()}`, + reason: "turn_deadline", + }); + }, + Math.max(0, r.deadlineMs - this.now()), + ); + } + private async queryOp(t: Turn, q: AgentHostOperationQuery) { + const o = t.ops.get(q.operationId); + if ( + !o || + o.receipt?.payloadDigest !== q.payloadDigest || + o.request.descriptorDigest !== q.descriptorDigest || + o.request.descriptor.kind !== q.kind + ) + throw Error("invalid query"); + o.sent.add( + this.sequenced(t, { + t: "operation_query", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: t.requestId, + fence: t.fence, + ...q, + } as never), + ); + } + private async cancelOp(t: Turn, c: AgentHostOperationCancel) { + const o = t.ops.get(c.operationId); + if (!o) throw Error("unknown operation"); + o.sent.add( + this.sequenced(t, { + t: "operation_cancel", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: t.requestId, + fence: t.fence, + ...c, + } as never), + ); + } + private async operationMessage( + t: Turn, + m: Exclude< + AgentHostClientMessage, + { + t: + | "hello" + | "attach" + | "start_turn" + | "consumption_ack" + | "turn_terminal_ack"; + } + >, + ) { + const o = t.ops.get(m.operationId); + if (!o) return this.invalid(t.owner!, m); + if (m.t === "operation_stream") return this.stream(t, o, m); + if ( + !o.sent.has(m.ackHostSeq) || + !this.applyReceipt(o, m.receipt) || + (m.t === "operation_query_receipt" && m.fromStreamSeq !== o.through + 1) + ) + return this.invalid(t.owner!, m); + this.complete(t); + } + private applyReceipt(o: Op, r: AgentOperationReceiptV1) { + if ( + r.kind !== o.request.descriptor.kind || + r.descriptorDigest !== o.request.descriptorDigest + ) + return false; + const json = JSON.stringify(r), + old = o.receipt; + if ( + old && + (rank(r.state) < rank(old.state) || + (r.state === old.state && json !== o.receiptJson) || + (terminal(old.state) && json !== o.receiptJson) || + r.planHash !== old.planHash || + r.authorityHash !== old.authorityHash || + r.payloadDigest !== old.payloadDigest || + JSON.stringify(r.actorIdentity) !== JSON.stringify(old.actorIdentity)) + ) + return false; + o.receipt = r; + o.receiptJson = json; + o.terminal = terminal(r.state); + if (o.terminal) { + this.clear(o.timer); + o.timer = undefined; + } + return true; + } + private async stream( + t: Turn, + o: Op, + m: Extract, + ) { + const n = Buffer.from(m.bytes, "base64url").byteLength; + if ( + o.terminal || + !o.receipt || + o.receipt.state === "prepared" || + m.streamSeq !== o.through + o.pending + 1 || + n > o.creditsBytes || + o.creditsChunks < 1 + ) + return this.close(t.owner!); + o.creditsBytes -= n; + o.creditsChunks--; + o.pending++; + try { + await this.hit("afterStreamAcceptedBeforeDriverDelivery"); + } catch (error) { + o.pending--; + throw error; + } + o.delivery = o.delivery.then(async () => { + try { + await t.driver.deliverOperationStream({ + operationId: m.operationId, + streamSeq: m.streamSeq, + encoding: m.encoding, + bytes: m.bytes, + }); + } catch { + o.pending--; + this.cancelTurn(t, "shutdown"); + return; + } + o.through = m.streamSeq; + o.pending--; + o.owedCreditBytes += n; + o.owedCreditChunks += 1; + await this.hit("afterDriverDeliveryBeforeStreamAck"); + if (this.active === t) { + this.credit(t, o, o.through, o.owedCreditBytes, o.owedCreditChunks); + o.owedCreditBytes = 0; + o.owedCreditChunks = 0; + } + this.complete(t); + }); + await o.delivery; + } + private credit( + t: Turn, + o: Op, + through: number, + bytes: number, + chunks: number, + ) { + const b = Math.min( + bytes, + MAX_AGENT_HOST_STREAM_BYTES - o.creditsBytes, + t.spec.limits.maxBufferedStreamBytes - o.creditsBytes, + ), + c = Math.min( + chunks, + MAX_AGENT_HOST_STREAM_CHUNKS - o.creditsChunks, + t.spec.limits.maxBufferedStreamChunks - o.creditsChunks, + ); + if (b <= 0 || c <= 0) return; + o.creditsBytes += b; + o.creditsChunks += c; + o.sent.add( + this.sequenced(t, { + t: "operation_stream_ack", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: t.requestId, + fence: t.fence, + operationId: o.request.operationId, + throughStreamSeq: through, + creditBytes: b, + creditChunks: c, + } as never), + ); + } + private buffer(t: Turn, m: Omit) { + const seq = ++t.seq, + bytes = encodeNdjsonFrame( + { ...m, hostSeq: seq }, + this.options.maxFrameBytes, + ); + t.replay.push({ seq, bytes }); + t.replayBytes += bytes.length; + while ( + t.replay.length > MAX_AGENT_HOST_REPLAY_FRAMES || + t.replayBytes > MAX_AGENT_HOST_REPLAY_BYTES + ) { + const f = t.replay.shift()!; + t.replayBytes -= f.bytes.length; + } + return seq; + } + private writeBuffered(t: Turn, seq: number) { + const f = t.replay.find((x) => x.seq === seq); + if (!f) throw Error("intent evicted before write"); + if (t.owner) this.sendBytes(t.owner, f.bytes); + } + private sequenced(t: Turn, m: Omit) { + const s = this.buffer(t, m); + this.writeBuffered(t, s); + return s; + } + private needsRecovery(t: Turn, r: AgentHostAttachResumeCursorV4) { + const oldest = t.replay[0]?.seq ?? t.seq + 1; + if (r.lastHostSeq > t.seq || r.lastHostSeq < oldest - 1) return true; + const c = new Map( + r.operations.map((x) => [x.operationId, x.throughStreamSeq]), + ); + return [...t.ops].some( + ([k, o]) => !o.terminal || (c.get(k) ?? 0) !== o.through, + ); + } + private async recover(t: Turn, r: AgentHostAttachResumeCursorV4) { + const c = new Map( + r.operations.map((x) => [x.operationId, x.throughStreamSeq]), + ); + for (const o of t.ops.values()) { + if (o.owedCreditChunks > 0) { + this.credit(t, o, o.through, o.owedCreditBytes, o.owedCreditChunks); + o.owedCreditBytes = 0; + o.owedCreditChunks = 0; + } + if (!o.receipt) { + o.sent.add( + this.sequenced(t, { + t: "operation_request", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: t.requestId, + fence: t.fence, + operationId: o.request.operationId, + descriptor: o.request.descriptor, + descriptorDigest: o.request.descriptorDigest, + deadlineMs: o.request.deadlineMs, + } as never), + ); + } else + await this.queryOp(t, { + operationId: o.request.operationId, + kind: o.request.descriptor.kind, + descriptorDigest: o.request.descriptorDigest, + payloadDigest: o.receipt.payloadDigest, + afterStreamSeq: c.get(o.request.operationId) ?? 0, + }); + } + } + private send(p: Peer, m: AgentHostServerMessage) { + try { + return this.sendBytes( + p, + encodeNdjsonFrame(m, this.options.maxFrameBytes), + ); + } catch { + return false; + } + } + private sendBytes(p: Peer, b: Buffer) { + if ( + p.closed || + !p.socket.writable || + p.socket.writableLength + p.queuedBytes + b.length > + MAX_AGENT_HOST_WRITABLE_BYTES + ) { + this.close(p); + return false; + } + p.queuedBytes += b.length; + p.writes = p.writes + .then( + () => + new Promise((ok, fail) => { + if ( + p.closed || + p.socket.writableLength + b.length > MAX_AGENT_HOST_WRITABLE_BYTES + ) + return fail(); + p.socket.write(b, (e) => (e ? fail(e) : ok())); + }), + ) + .finally(() => { + p.queuedBytes -= b.length; + }) + .catch(() => this.close(p)); + return true; + } + private disconnected(p: Peer) { + p.closed = true; + this.peers.delete(p); + this.clear(p.timer); + if (this.attaching === p) this.attaching = undefined; + const t = this.active; + if (t?.owner === p) + t.reconnect = this.set( + () => { + void this.hit("onReconnectDeadline").finally(() => + this.cancelTurn(t, "reconnect_deadline"), + ); + }, + this.duration( + this.options.reconnectGraceMs, + 30_000, + "reconnectGraceMs", + ), + ); + else if (this.owner === p) this.owner = undefined; + } + private cancelTurn(t: Turn, reason: AgentHostOperationCancelV4["reason"]) { + if (this.active !== t || t.cancelling) return; + t.cancelling = true; + for (const o of t.ops.values()) + if (!o.terminal) + void this.cancelOp(t, { + operationId: o.request.operationId, + cancelId: `cancel-${crypto.randomUUID()}`, + reason, + }).catch(() => {}); + let p; + try { + p = Promise.resolve(t.driver.cancel()); + } catch (e) { + p = Promise.reject(e); + } + const timer = this.set( + () => { + if (!t.cancelSettled) this.poisoned = true; + }, + this.duration( + this.options.cancellationDeadlineMs, + 5000, + "cancellationDeadlineMs", + ), + ); + void p.finally(() => { + this.clear(timer); + t.cancelSettled = true; + this.complete(t); + }); + } + private consumptionAck( + t: Turn, + m: Extract, + ) { + if (m.ackHostSeq < t.acknowledgedHostSeq || m.ackHostSeq > t.seq) + return this.invalid(t.owner!, m); + const cursors = new Map( + m.operations.map((item) => [item.operationId, item.throughStreamSeq]), + ); + for (const [operationId, throughStreamSeq] of cursors) { + const operation = t.ops.get(operationId); + const previous = t.acknowledgedStreams.get(operationId) ?? 0; + if ( + !operation || + throughStreamSeq < previous || + throughStreamSeq > operation.through + ) + return this.invalid(t.owner!, m); + } + t.acknowledgedHostSeq = m.ackHostSeq; + for (const [operationId, throughStreamSeq] of cursors) + t.acknowledgedStreams.set(operationId, throughStreamSeq); + this.complete(t); + } + private terminalAck( + t: Turn, + m: Extract, + ) { + const terminal = t.terminal; + if ( + !terminal || + m.ackHostSeq !== terminal.hostSeq || + m.resultDigest !== terminal.resultDigest || + m.receiptsDigest !== terminal.receiptsDigest + ) + return this.invalid(t.owner!, m); + this.clear(t.deadline); + this.clear(t.reconnect); + for (const operation of t.ops.values()) this.clear(operation.timer); + this.active = undefined; + if (this.owner === t.owner) this.owner = undefined; + this.close(t.owner!); + } + private complete(t: Turn) { + if ( + this.active !== t || + t.terminal || + t.completing || + !t.runSettled || + [...t.ops.values()].some((o) => !o.terminal || o.pending) || + (t.cancelling && !t.cancelSettled) || + t.acknowledgedHostSeq !== t.seq || + [...t.ops].some( + ([operationId, operation]) => + (t.acknowledgedStreams.get(operationId) ?? 0) !== operation.through, + ) + ) + return; + t.completing = true; + void this.projectTerminal(t).catch(() => { + t.completing = false; + this.cancelTurn(t, "shutdown"); + }); + } + private async projectTerminal(t: Turn) { + const result = t.result!; + const operations = await projectAgentTurnTerminalOperationsV1( + [...t.ops].map(([operationId, operation]) => ({ + operationId, + receipt: operation.receipt!, + throughStreamSeq: operation.through, + })), + ); + const [resultDigest, receiptsDigest] = await Promise.all([ + hashAgentTurnResultV1(result), + hashAgentTurnTerminalReceiptsV1(operations), + ]); + if (this.active !== t || t.terminal) return; + const finalAckHostSeq = t.acknowledgedHostSeq; + const frame = { + t: "turn_terminal" as const, + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: t.requestId, + fence: t.fence, + hostGeneration: this.options.hostGeneration, + hostIncarnation: this.options.hostIncarnation, + result: { status: result.status }, + resultDigest, + receiptsDigest, + finalAckHostSeq, + operations, + }; + const hostSeq = this.buffer(t, frame as never); + t.terminal = Object.freeze({ + ...frame, + hostSeq, + result: Object.freeze(frame.result), + }); + this.writeBuffered(t, hostSeq); + } + private close(p: Peer) { + if (!p.closed) { + p.closed = true; + p.socket.destroy(); + } + } +} +export function createAgentHost(options: AgentHostOptions) { + return new AgentHost(options); +} diff --git a/packages/core/opensession-server/src/agent-host/ledger-accounting.test.ts b/packages/core/opensession-server/src/agent-host/ledger-accounting.test.ts new file mode 100644 index 0000000000..e92aab5b2e --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/ledger-accounting.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test"; +import { + LEDGER_ORDINARY_PHYSICAL_MAX, + LEDGER_PROTECTED_PHYSICAL_BYTES, + LEDGER_TOTAL_PHYSICAL_MAX, + LedgerAccountingContradictionError, + LedgerCapacityError, + assertCommittedBound, + conservativeTransactionBound, + preflightLiability, +} from "./ledger-accounting"; + +describe("Host ledger physical accounting", () => { + test("keeps the protected 64 MiB inside the 512 MiB ceiling", () => { + expect(LEDGER_TOTAL_PHYSICAL_MAX - LEDGER_ORDINARY_PHYSICAL_MAX).toBe( + LEDGER_PROTECTED_PHYSICAL_BYTES, + ); + }); + test("bounds 2 KiB chunks, B-tree splits, WAL frames and checkpoint peak", () => { + const small = conservativeTransactionBound({ + encryptedPlaintextBytes: 1, + rowsInserted: 1, + rowsUpdated: 0, + rowsDeleted: 0, + affectedIndexes: 1, + }); + const split = conservativeTransactionBound({ + encryptedPlaintextBytes: 8192, + rowsInserted: 2, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 4, + checkpointPossible: true, + }); + expect(split).toBeGreaterThan(small); + expect(split).toBeGreaterThan(8192); + }); + test("fails closed for turn, ordinary global, free-space and model contradictions", () => { + const shape = { + encryptedPlaintextBytes: 1, + rowsInserted: 1, + rowsUpdated: 0, + rowsDeleted: 0, + affectedIndexes: 1, + }; + const bound = conservativeTransactionBound(shape); + expect(() => + preflightLiability({ + shape, + writeClass: "ordinary", + currentPhysicalBytes: 0, + globalChargedBytes: 0, + turnChargedBytes: 32 * 1024 * 1024 - bound + 1, + activeLiabilityBytes: 0, + availableBytes: 1e9, + }), + ).toThrow(LedgerCapacityError); + expect(() => + preflightLiability({ + shape, + writeClass: "ordinary", + currentPhysicalBytes: LEDGER_ORDINARY_PHYSICAL_MAX - bound + 1, + globalChargedBytes: 0, + turnChargedBytes: 0, + activeLiabilityBytes: 0, + availableBytes: 1e9, + }), + ).toThrow(LedgerCapacityError); + expect(() => + preflightLiability({ + shape, + writeClass: "emergency", + currentPhysicalBytes: 0, + globalChargedBytes: 0, + turnChargedBytes: 0, + activeLiabilityBytes: 0, + availableBytes: 0, + reserveAvailableBytes: bound - 1, + }), + ).toThrow(LedgerCapacityError); + const snapshot = { + mainBytes: 0, + walBytes: 0, + shmBytes: 0, + totalBytes: 0, + availableBytes: 1e9, + }; + expect(() => + assertCommittedBound( + snapshot, + { ...snapshot, totalBytes: bound + 1 }, + { bytes: bound, writeClass: "ordinary", turnCharge: bound }, + ), + ).toThrow(LedgerAccountingContradictionError); + }); + test("accepts exact turn/global/free-space boundaries and rejects one byte beyond", () => { + const shape = { + encryptedPlaintextBytes: 1, + rowsInserted: 1, + rowsUpdated: 0, + rowsDeleted: 0, + affectedIndexes: 1, + }; + const bound = conservativeTransactionBound(shape); + const base = { + shape, + writeClass: "ordinary" as const, + currentPhysicalBytes: 0, + globalChargedBytes: LEDGER_ORDINARY_PHYSICAL_MAX - bound, + turnChargedBytes: 32 * 1024 * 1024 - bound, + activeLiabilityBytes: 0, + availableBytes: bound, + }; + expect(preflightLiability(base).bytes).toBe(bound); + expect(() => + preflightLiability({ + ...base, + turnChargedBytes: base.turnChargedBytes + 1, + }), + ).toThrow(LedgerCapacityError); + expect(() => + preflightLiability({ + ...base, + globalChargedBytes: base.globalChargedBytes + 1, + }), + ).toThrow(LedgerCapacityError); + expect(() => + preflightLiability({ ...base, availableBytes: bound - 1 }), + ).toThrow(LedgerCapacityError); + expect(() => + preflightLiability({ + ...base, + availableBytes: 0, + reserveAvailableBytes: bound, + }), + ).toThrow(LedgerCapacityError); + const emergency = { + ...base, + writeClass: "emergency" as const, + globalChargedBytes: LEDGER_TOTAL_PHYSICAL_MAX - bound, + turnChargedBytes: 0, + availableBytes: 0, + reserveAvailableBytes: bound, + }; + expect(preflightLiability(emergency).bytes).toBe(bound); + expect(() => + preflightLiability({ + ...emergency, + globalChargedBytes: emergency.globalChargedBytes + 1, + }), + ).toThrow(LedgerCapacityError); + }); + test("serializes concurrent liabilities at the ordinary boundary", () => { + const shape = { + encryptedPlaintextBytes: 1, + rowsInserted: 1, + rowsUpdated: 0, + rowsDeleted: 0, + affectedIndexes: 1, + }; + const bound = conservativeTransactionBound(shape); + const admitted = preflightLiability({ + shape, + writeClass: "ordinary", + currentPhysicalBytes: 0, + globalChargedBytes: LEDGER_ORDINARY_PHYSICAL_MAX - 2 * bound, + turnChargedBytes: 0, + activeLiabilityBytes: bound, + availableBytes: bound, + }); + expect(admitted.bytes).toBe(bound); + expect(() => + preflightLiability({ + shape, + writeClass: "ordinary", + currentPhysicalBytes: 0, + globalChargedBytes: LEDGER_ORDINARY_PHYSICAL_MAX - 2 * bound + 1, + turnChargedBytes: 0, + activeLiabilityBytes: bound, + availableBytes: bound, + }), + ).toThrow(LedgerCapacityError); + }); + test("charges checkpoint peak as a second database-page copy", () => { + const shape = { + encryptedPlaintextBytes: 4096, + rowsInserted: 2, + rowsUpdated: 1, + rowsDeleted: 1, + affectedIndexes: 3, + }; + const walOnly = conservativeTransactionBound(shape); + const checkpoint = conservativeTransactionBound({ + ...shape, + checkpointPossible: true, + }); + expect(checkpoint - walOnly).toBeGreaterThan(0); + expect((checkpoint - walOnly) % 4096).toBe(0); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/ledger-accounting.ts b/packages/core/opensession-server/src/agent-host/ledger-accounting.ts new file mode 100644 index 0000000000..93b4c1fc48 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/ledger-accounting.ts @@ -0,0 +1,181 @@ +import { statfsSync, statSync } from "node:fs"; + +export const LEDGER_PAGE_BYTES = 4096; +export const LEDGER_WAL_FRAME_BYTES = 4096 + 24; +export const LEDGER_ENCRYPTED_CHUNK_BYTES = 2048; +export const LEDGER_TURN_PHYSICAL_MAX = 32 * 1024 * 1024; +export const LEDGER_ORDINARY_PHYSICAL_MAX = 448 * 1024 * 1024; +export const LEDGER_TOTAL_PHYSICAL_MAX = 512 * 1024 * 1024; +export const LEDGER_PROTECTED_PHYSICAL_BYTES = 64 * 1024 * 1024; + +export type LedgerWriteClass = "ordinary" | "emergency"; +export interface PhysicalSnapshot { + readonly mainBytes: number; + readonly walBytes: number; + readonly shmBytes: number; + readonly totalBytes: number; + readonly availableBytes: number; +} +export interface WriteShape { + readonly encryptedPlaintextBytes: number; + readonly rowsInserted: number; + readonly rowsUpdated: number; + readonly rowsDeleted: number; + readonly affectedIndexes: number; + readonly checkpointPossible?: boolean; +} +export interface Liability { + readonly bytes: number; + readonly writeClass: LedgerWriteClass; + readonly turnCharge: number; +} +export class LedgerCapacityError extends Error { + constructor(message: string) { + super(message); + this.name = "LedgerCapacityError"; + } +} +export class LedgerAccountingContradictionError extends Error { + constructor(message: string) { + super(message); + this.name = "LedgerAccountingContradictionError"; + } +} + +const fileBytes = (path: string): number => { + try { + return statSync(path).size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0; + throw error; + } +}; +export interface LedgerPhysicalAccounting { + snapshot(dbPath: string): PhysicalSnapshot; +} + +export const nodeLedgerPhysicalAccounting: LedgerPhysicalAccounting = { + snapshot: snapshotPhysical, +}; + +export function snapshotPhysical(dbPath: string): PhysicalSnapshot { + const mainBytes = fileBytes(dbPath); + const walBytes = fileBytes(`${dbPath}-wal`); + const shmBytes = fileBytes(`${dbPath}-shm`); + const fs = statfsSync(dbPath); + return { + mainBytes, + walBytes, + shmBytes, + totalBytes: mainBytes + walBytes + shmBytes, + availableBytes: Number(fs.bavail) * Number(fs.bsize), + }; +} + +/** + * Proven-conservative transaction model for this schema, not exact SQLite + * attribution. Each 2 KiB plaintext chunk is charged for one data leaf and one + * overflow page; each touched index is charged a leaf plus parent/root split. + * Every dirty page is charged both its database page and a WAL frame. The fixed + * twelve-page term covers schema/accounting rows, freelist/trunk churn, WAL + * header rounding and an extra root split. A checkpoint-capable transaction is + * charged a second database-page copy for every dirty page. This deliberately + * overstates ordinary writes. Bun does not expose SQLite's dirty-page set or + * checkpoint peak, so measured post-commit deltas can validate this upper bound + * but cannot prove exact per-turn physical attribution. + */ +export function conservativeTransactionBound(shape: WriteShape): number { + for (const value of [ + shape.encryptedPlaintextBytes, + shape.rowsInserted, + shape.rowsUpdated, + shape.rowsDeleted, + shape.affectedIndexes, + ]) + if (!Number.isSafeInteger(value) || value < 0) + throw new Error("invalid ledger write shape"); + const chunks = Math.max( + 1, + Math.ceil(shape.encryptedPlaintextBytes / LEDGER_ENCRYPTED_CHUNK_BYTES), + ); + const rowTouches = shape.rowsInserted + shape.rowsUpdated + shape.rowsDeleted; + const dataPages = chunks * 2 + rowTouches * 3; + const indexPages = Math.max(1, rowTouches) * shape.affectedIndexes * 3; + const dirtyPages = 12 + dataPages + indexPages; + const wal = 32 + dirtyPages * LEDGER_WAL_FRAME_BYTES; + const database = dirtyPages * LEDGER_PAGE_BYTES; + return wal + database + (shape.checkpointPossible ? database : 0); +} + +function capacityInteger(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 0) + throw new LedgerCapacityError(`invalid ${label}`); +} + +export function preflightLiability(input: { + shape: WriteShape; + writeClass: LedgerWriteClass; + currentPhysicalBytes: number; + globalChargedBytes: number; + turnChargedBytes: number; + activeLiabilityBytes: number; + availableBytes: number; + /** Physically allocated generation reserve available only to emergency writes. */ + reserveAvailableBytes?: number; + chargeTurn?: boolean; +}): Liability { + for (const [value, label] of [ + [input.currentPhysicalBytes, "current physical bytes"], + [input.globalChargedBytes, "global charged bytes"], + [input.turnChargedBytes, "turn charged bytes"], + [input.activeLiabilityBytes, "active liability bytes"], + [input.availableBytes, "available bytes"], + [input.reserveAvailableBytes ?? 0, "reserve available bytes"], + ] as const) + capacityInteger(value, label); + const bytes = conservativeTransactionBound(input.shape); + if ( + input.chargeTurn !== false && + input.turnChargedBytes + bytes > LEDGER_TURN_PHYSICAL_MAX + ) + throw new LedgerCapacityError("turn physical charge would exceed 32 MiB"); + const projected = + input.globalChargedBytes + input.activeLiabilityBytes + bytes; + const physicalProjected = + input.currentPhysicalBytes + input.activeLiabilityBytes + bytes; + const ceiling = + input.writeClass === "ordinary" + ? LEDGER_ORDINARY_PHYSICAL_MAX + : LEDGER_TOTAL_PHYSICAL_MAX; + if (projected > ceiling || physicalProjected > ceiling) + throw new LedgerCapacityError( + `${input.writeClass} Host ledger ceiling would be exceeded`, + ); + const reserveAvailable = input.reserveAvailableBytes ?? 0; + if (input.writeClass === "emergency" && bytes > reserveAvailable) + throw new LedgerCapacityError( + "emergency liability exceeds physically allocated reserve", + ); + if ( + bytes > + input.availableBytes + + (input.writeClass === "emergency" ? reserveAvailable : 0) + ) + throw new LedgerCapacityError( + "insufficient filesystem bytes for conservative ledger liability", + ); + return { bytes, writeClass: input.writeClass, turnCharge: bytes }; +} + +export function assertCommittedBound( + before: PhysicalSnapshot, + after: PhysicalSnapshot, + liability: Liability, +): number { + const growth = Math.max(0, after.totalBytes - before.totalBytes); + if (growth > liability.bytes) + throw new LedgerAccountingContradictionError( + `committed physical growth ${growth} exceeded modeled bound ${liability.bytes}`, + ); + return growth; +} diff --git a/packages/core/opensession-server/src/agent-host/ledger-crypto.test.ts b/packages/core/opensession-server/src/agent-host/ledger-crypto.test.ts new file mode 100644 index 0000000000..8508cdb6e7 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/ledger-crypto.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { HostLedgerKeyring } from "./ledger-crypto"; + +const key = (id = "k1", fill = 7) => ({ + id, + encryptionKey: new Uint8Array(32).fill(fill), + lookupKey: new Uint8Array(32).fill(8), + decryptNotBeforeMs: 0, + decryptNotAfterMs: 10_000, +}); +const aad = { + table: "turns", + opaquePrimaryKey: "a".repeat(64), + exactFence: "f".repeat(64), +}; +describe("Host ledger crypto", () => { + test("uses random AES-GCM envelopes and rejects tamper/AAD changes", () => { + const ring = new HostLedgerKeyring({ activeKeyId: "k1", keys: [key()] }); + const clear = new TextEncoder().encode("forbidden plaintext fixture"); + const one = ring.encrypt(clear, aad, 1), + two = ring.encrypt(clear, aad, 1); + expect(one).not.toBe(two); + expect(new TextDecoder().decode(ring.decrypt(one, aad, 1))).toBe( + "forbidden plaintext fixture", + ); + const parts = one.split("."); + parts[4] = `${parts[4]!.startsWith("A") ? "B" : "A"}${parts[4]!.slice(1)}`; + expect(() => ring.decrypt(parts.join("."), aad, 1)).toThrow( + /authentication|envelope/, + ); + expect(() => + ring.decrypt(one, { ...aad, exactFence: "b".repeat(64) }, 1), + ).toThrow(/authentication/); + }); + test("rotates writes while bounded old keys remain decrypt-only", () => { + const old = new HostLedgerKeyring({ + activeKeyId: "old", + keys: [key("old", 2)], + }); + const envelope = old.encrypt(new Uint8Array([1, 2, 3]), aad, 1); + const rotated = new HostLedgerKeyring({ + activeKeyId: "new", + keys: [key("new", 3), key("old", 2)], + maxOldKeys: 1, + }); + expect([...rotated.decrypt(envelope, aad, 1)]).toEqual([1, 2, 3]); + expect(rotated.encrypt(new Uint8Array([4]), aad, 1).split(".")[1]).toBe( + "new", + ); + expect( + () => + new HostLedgerKeyring({ + activeKeyId: "new", + keys: [key("new"), key("old"), key("extra")], + maxOldKeys: 1, + }), + ).toThrow(/bound/); + }); + test("HMAC lookup domains do not expose or alias raw IDs", () => { + const ring = new HostLedgerKeyring({ activeKeyId: "k1", keys: [key()] }); + expect(ring.opaqueId("session", "secret-session")).toMatch( + /^[a-f0-9]{64}$/, + ); + expect(ring.opaqueId("session", "secret-session")).not.toBe( + ring.opaqueId("turn", "secret-session"), + ); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/ledger-crypto.ts b/packages/core/opensession-server/src/agent-host/ledger-crypto.ts new file mode 100644 index 0000000000..7e7237ee47 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/ledger-crypto.ts @@ -0,0 +1,231 @@ +import { + createCipheriv, + createDecipheriv, + createHmac, + createSecretKey, + randomBytes, + timingSafeEqual, + type KeyObject, +} from "node:crypto"; + +export const AGENT_HOST_LEDGER_ENVELOPE_VERSION = "ahrl1" as const; +export const AGENT_HOST_LEDGER_SCHEMA = 1 as const; +const KEY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const B64URL = /^[A-Za-z0-9_-]+$/; + +export interface HostLedgerKey { + readonly id: string; + /** Exactly 32 mutable bytes. The constructor copies this into a KeyObject. */ + readonly encryptionKey: Uint8Array; + /** At least 32 mutable bytes. Used only for domain-separated opaque lookups. */ + readonly lookupKey: Uint8Array; + readonly decryptNotBeforeMs: number; + readonly decryptNotAfterMs: number; +} +export interface HostLedgerKeyringInput { + readonly activeKeyId: string; + readonly keys: readonly HostLedgerKey[]; + readonly maxOldKeys?: number; +} +interface LoadedKey { + id: string; + encryptionKey: KeyObject; + lookupKey: KeyObject; + decryptNotBeforeMs: number; + decryptNotAfterMs: number; +} +export interface LedgerAad { + readonly table: string; + readonly opaquePrimaryKey: string; + readonly exactFence: string; +} + +const encoder = new TextEncoder(); +function aadBytes(aad: LedgerAad): Uint8Array { + if (!/^[a-z][a-z0-9_]{0,63}$/.test(aad.table)) + throw new Error("invalid ledger AAD table"); + if (!/^[a-f0-9]{64}$/.test(aad.opaquePrimaryKey)) + throw new Error("invalid ledger AAD primary key"); + if (!aad.exactFence || encoder.encode(aad.exactFence).byteLength > 2048) + throw new Error("invalid ledger AAD fence"); + return encoder.encode( + `opensession-agent-host-ledger-aad-v1\u0000${AGENT_HOST_LEDGER_SCHEMA}\u0000${aad.table}\u0000${aad.opaquePrimaryKey}\u0000${aad.exactFence}`, + ); +} + +/** + * Application-level ledger keyring. It deliberately accepts mutable buffers so + * callers can erase provisioning material after construction. Node KeyObjects, + * OpenSSL internals, strings, SQLite copies and the JS GC cannot be reliably + * wiped; callers must treat process isolation and short plaintext lifetimes as + * part of the boundary. + */ +export class HostLedgerKeyring { + readonly #keys = new Map(); + readonly #active: LoadedKey; + + constructor(input: HostLedgerKeyringInput) { + const maxOldKeys = input.maxOldKeys ?? 3; + if ( + !KEY_ID.test(input.activeKeyId) || + !Number.isSafeInteger(maxOldKeys) || + maxOldKeys < 0 + ) + throw new Error("invalid Host ledger keyring"); + if (input.keys.length < 1 || input.keys.length > maxOldKeys + 1) + throw new Error("Host ledger keyring exceeds decrypt-only key bound"); + for (const supplied of input.keys) { + if ( + !KEY_ID.test(supplied.id) || + this.#keys.has(supplied.id) || + supplied.encryptionKey.byteLength !== 32 || + supplied.lookupKey.byteLength < 32 || + !Number.isSafeInteger(supplied.decryptNotBeforeMs) || + !Number.isSafeInteger(supplied.decryptNotAfterMs) || + supplied.decryptNotBeforeMs < 0 || + supplied.decryptNotAfterMs < supplied.decryptNotBeforeMs + ) + throw new Error("invalid Host ledger key"); + const encryptionCopy = Buffer.from(supplied.encryptionKey); + const lookupCopy = Buffer.from(supplied.lookupKey); + try { + this.#keys.set(supplied.id, { + id: supplied.id, + encryptionKey: createSecretKey(encryptionCopy), + lookupKey: createSecretKey(lookupCopy), + decryptNotBeforeMs: supplied.decryptNotBeforeMs, + decryptNotAfterMs: supplied.decryptNotAfterMs, + }); + } finally { + encryptionCopy.fill(0); + lookupCopy.fill(0); + } + } + const active = this.#keys.get(input.activeKeyId); + if (!active) throw new Error("active Host ledger write key is absent"); + const lookupProbe = Buffer.from( + "opensession-agent-host-ledger-lookup-key-probe-v1", + ); + const expectedLookup = createHmac("sha256", active.lookupKey) + .update(lookupProbe) + .digest(); + try { + for (const key of this.#keys.values()) { + const candidate = createHmac("sha256", key.lookupKey) + .update(lookupProbe) + .digest(); + try { + if (!timingSafeEqual(expectedLookup, candidate)) + throw new Error( + "Host ledger lookup key must remain stable across encryption-key rotation", + ); + } finally { + candidate.fill(0); + } + } + } finally { + lookupProbe.fill(0); + expectedLookup.fill(0); + } + this.#active = active; + } + + get activeKeyId(): string { + return this.#active.id; + } + + opaqueId( + kind: "session" | "run" | "turn" | "operation" | "receipt", + rawId: string, + ): string { + if (!rawId || encoder.encode(rawId).byteLength > 1024) + throw new Error(`invalid ${kind} id`); + return createHmac("sha256", this.#active.lookupKey) + .update(`opensession-agent-host-ledger-lookup-v1\u0000${kind}\u0000`) + .update(rawId, "utf8") + .digest("hex"); + } + + encrypt(plaintext: Uint8Array, aad: LedgerAad, nowMs: number): string { + if ( + !Number.isSafeInteger(nowMs) || + nowMs < this.#active.decryptNotBeforeMs || + nowMs > this.#active.decryptNotAfterMs + ) + throw new Error("active Host ledger key is outside its write window"); + const nonce = randomBytes(12); + const copy = Buffer.from(plaintext); + try { + const cipher = createCipheriv( + "aes-256-gcm", + this.#active.encryptionKey, + nonce, + { authTagLength: 16 }, + ); + cipher.setAAD(aadBytes(aad)); + const ciphertext = Buffer.concat([cipher.update(copy), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${AGENT_HOST_LEDGER_ENVELOPE_VERSION}.${this.#active.id}.${nonce.toString("base64url")}.${ciphertext.toString("base64url")}.${tag.toString("base64url")}`; + } finally { + copy.fill(0); + nonce.fill(0); + } + } + + decrypt(envelope: string, aad: LedgerAad, nowMs: number): Uint8Array { + const parts = envelope.split("."); + if ( + parts.length !== 5 || + parts[0] !== AGENT_HOST_LEDGER_ENVELOPE_VERSION || + !KEY_ID.test(parts[1]!) || + !parts.slice(2).every((v) => B64URL.test(v)) + ) + throw new Error("invalid Host ledger ciphertext envelope"); + const key = this.#keys.get(parts[1]!); + if ( + !key || + !Number.isSafeInteger(nowMs) || + nowMs < key.decryptNotBeforeMs || + nowMs > key.decryptNotAfterMs + ) + throw new Error("Host ledger decrypt key unavailable or outside window"); + const nonce = Buffer.from(parts[2]!, "base64url"); + const ciphertext = Buffer.from(parts[3]!, "base64url"); + const tag = Buffer.from(parts[4]!, "base64url"); + if (nonce.byteLength !== 12 || tag.byteLength !== 16) + throw new Error("invalid Host ledger ciphertext envelope"); + try { + const decipher = createDecipheriv( + "aes-256-gcm", + key.encryptionKey, + nonce, + { authTagLength: 16 }, + ); + decipher.setAAD(aadBytes(aad)); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); + } catch { + throw new Error("Host ledger ciphertext authentication failed"); + } finally { + nonce.fill(0); + ciphertext.fill(0); + tag.fill(0); + } + } + + verifyOpaqueId( + kind: "session" | "run" | "turn" | "operation" | "receipt", + rawId: string, + expected: string, + ): boolean { + if (!/^[a-f0-9]{64}$/.test(expected)) return false; + const actual = Buffer.from(this.opaqueId(kind, rawId), "hex"); + const wanted = Buffer.from(expected, "hex"); + try { + return timingSafeEqual(actual, wanted); + } finally { + actual.fill(0); + wanted.fill(0); + } + } +} diff --git a/packages/core/opensession-server/src/agent-host/ledger-enospc-qualification.test.ts b/packages/core/opensession-server/src/agent-host/ledger-enospc-qualification.test.ts new file mode 100644 index 0000000000..83636b893a --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/ledger-enospc-qualification.test.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LEDGER_ORDINARY_PHYSICAL_MAX, + type LedgerPhysicalAccounting, + type PhysicalSnapshot, +} from "./ledger-accounting"; +import { + SQLiteHostRecoveryLedger, + type LedgerFaultBoundary, + type SQLiteHostLedgerOptions, + type TurnFence, +} from "./sqlite-ledger"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); +}); + +const fence = (n = 1): TurnFence => ({ + sessionId: `qualification-session-${n}`, + runId: `qualification-run-${n}`, + turnId: `qualification-turn-${n}`, + generation: 1, +}); +const digest = (n: number) => n.toString(16).padStart(64, "0"); +const enospc = () => + Object.assign(new Error("qualified ENOSPC"), { code: "ENOSPC" }); + +function fixture(overrides: Partial = {}) { + const dir = mkdtempSync(join(tmpdir(), "host-ledger-enospc-")); + dirs.push(dir); + const dbPath = join(dir, "recovery.sqlite"); + const options: SQLiteHostLedgerOptions = { + dbPath, + writerNonce: "qualification-writer-0001", + now: () => 1_000, + keyring: { + activeKeyId: "active", + keys: [ + { + id: "active", + encryptionKey: new Uint8Array(32).fill(4), + lookupKey: new Uint8Array(32).fill(9), + decryptNotBeforeMs: 0, + decryptNotAfterMs: Number.MAX_SAFE_INTEGER, + }, + ], + }, + ...overrides, + }; + return { dbPath, options, open: () => new SQLiteHostRecoveryLedger(options) }; +} +function admit(ledger: SQLiteHostRecoveryLedger, n = 1) { + return ledger.admitTurn({ + fence: fence(n), + authorityHash: digest(1), + recoveryDescriptor: { qualification: n }, + admittedAtMs: 1_000, + }); +} +function rows(path: string) { + const db = new Database(path, { readonly: true }); + try { + return db + .query<{ count: number }, []>("SELECT COUNT(*) AS count FROM turns") + .get()!.count; + } finally { + db.close(); + } +} + +describe("Agent Host ledger physical/ENOSPC qualification", () => { + const transactionBoundaries: LedgerFaultBoundary[] = [ + "transaction:before-begin", + "transaction:after-begin", + "transaction:before-commit", + "transaction:after-commit", + ]; + for (const boundary of transactionBoundaries) { + test(`fails closed exactly once at ${boundary}`, () => { + let armed = false; + let hits = 0; + const f = fixture({ + injectFault: (seen) => { + if (armed && seen === boundary) { + hits++; + throw enospc(); + } + }, + }); + const ledger = f.open(); + armed = true; + expect(() => admit(ledger)).toThrow("qualified ENOSPC"); + expect(hits).toBe(1); + expect(rows(f.dbPath)).toBe( + boundary === "transaction:after-commit" ? 1 : 0, + ); + armed = false; + expect(() => admit(ledger, 2)).not.toThrow(); + ledger.close(); + const reopened = f.open(); + expect( + reopened.scanRecover().filter((record) => record.kind === "turn"), + ).toHaveLength(boundary === "transaction:after-commit" ? 2 : 1); + reopened.close(); + }); + } + + for (const boundary of [ + "checkpoint:before", + "checkpoint:after", + "reserve:before-recreate", + "reserve:after-recreate", + ] as const) { + test(`does not retry committed recovery work at ${boundary}`, () => { + let armed = false; + let hits = 0; + const f = fixture({ + injectFault: (seen) => { + if (armed && seen === boundary) { + hits++; + throw enospc(); + } + }, + }); + const ledger = f.open(); + admit(ledger); + armed = true; + expect(() => ledger.deleteSession(fence().sessionId, 1_001)).toThrow( + "qualified ENOSPC", + ); + expect(hits).toBe(1); + expect(rows(f.dbPath)).toBe(0); + armed = false; + ledger.close(); + const reopened = f.open(); + expect(() => admit(reopened, 2)).not.toThrow(); + reopened.close(); + }); + } + + test("ordinary admission cannot consume reserve; emergency quarantine can; checkpoint restores it", () => { + let projected = 0; + const physical: LedgerPhysicalAccounting = { + snapshot(path): PhysicalSnapshot { + const mainBytes = statSync(path).size; + return { + mainBytes, + walBytes: 0, + shmBytes: 0, + totalBytes: projected || mainBytes, + availableBytes: 1024 ** 4, + }; + }, + }; + const f = fixture({ physicalAccounting: physical }); + const ledger = f.open(); + admit(ledger); + projected = LEDGER_ORDINARY_PHYSICAL_MAX; + expect(() => admit(ledger, 2)).toThrow("ordinary Host ledger ceiling"); + expect(() => + ledger.quarantine(fence(), "evidence", { reason: "recover" }, 1_001), + ).not.toThrow(); + projected = statSync(f.dbPath).size; + expect(() => ledger.deleteSession(fence().sessionId, 1_002)).not.toThrow(); + expect(() => admit(ledger, 2)).not.toThrow(); + ledger.close(); + }); + + test("a live sole writer fences another generation and crash/reopen preserves committed work", () => { + let fault = true; + const f = fixture({ + injectFault: (boundary) => { + if (fault && boundary === "transaction:after-commit") throw enospc(); + }, + }); + const first = f.open(); + expect(() => admit(first)).toThrow("qualified ENOSPC"); + fault = false; + expect( + () => + new SQLiteHostRecoveryLedger({ + ...f.options, + writerNonce: "qualification-writer-0002", + }), + ).toThrow("live sole writer"); + first.close(); + const nextGeneration = new SQLiteHostRecoveryLedger({ + ...f.options, + writerNonce: "qualification-writer-0002", + }); + expect(nextGeneration.scanRecover()).toContainEqual( + expect.objectContaining({ kind: "turn", phase: "admitted" }), + ); + nextGeneration.close(); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/ledger-schema.ts b/packages/core/opensession-server/src/agent-host/ledger-schema.ts new file mode 100644 index 0000000000..0bc2cc3358 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/ledger-schema.ts @@ -0,0 +1,128 @@ +import type { Database } from "bun:sqlite"; + +export const AGENT_HOST_LEDGER_SCHEMA_VERSION = 1; +export const AGENT_HOST_LEDGER_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +export const AGENT_HOST_LEDGER_SCHEMA_SQL = ` +CREATE TABLE meta (singleton INTEGER PRIMARY KEY CHECK(singleton=1), schema_version INTEGER NOT NULL CHECK(schema_version=1), created_at INTEGER NOT NULL, emergency_mode INTEGER NOT NULL DEFAULT 0 CHECK(emergency_mode IN (0,1))) STRICT; +CREATE TABLE writer (singleton INTEGER PRIMARY KEY CHECK(singleton=1), claim_nonce TEXT NOT NULL CHECK(length(claim_nonce) BETWEEN 16 AND 128), process_id INTEGER NOT NULL CHECK(process_id>0), claimed_at INTEGER NOT NULL) STRICT; +CREATE TABLE accounting (singleton INTEGER PRIMARY KEY CHECK(singleton=1), global_charge INTEGER NOT NULL CHECK(global_charge>=0), physical_high_water INTEGER NOT NULL CHECK(physical_high_water>=0), active_liability INTEGER NOT NULL DEFAULT 0 CHECK(active_liability>=0)) STRICT; +CREATE TABLE turns ( + session_key TEXT NOT NULL CHECK(length(session_key)=64), turn_key TEXT PRIMARY KEY CHECK(length(turn_key)=64), run_key TEXT NOT NULL CHECK(length(run_key)=64), + phase TEXT NOT NULL CHECK(phase IN ('admitted','running','terminal','indeterminate','quarantined','deleted')), + fence_digest TEXT NOT NULL CHECK(length(fence_digest)=64), fence_ciphertext TEXT NOT NULL, fence_key_id TEXT NOT NULL, authority_ciphertext TEXT NOT NULL, authority_key_id TEXT NOT NULL, + admitted_at INTEGER NOT NULL, terminal_at INTEGER, charged_bytes INTEGER NOT NULL DEFAULT 0 CHECK(charged_bytes>=0), byte_count INTEGER NOT NULL CHECK(byte_count>=0) +) STRICT; +CREATE INDEX turns_phase_time ON turns(phase,terminal_at); +CREATE INDEX turns_session ON turns(session_key); +CREATE TABLE operations ( + operation_key TEXT PRIMARY KEY CHECK(length(operation_key)=64), session_key TEXT NOT NULL CHECK(length(session_key)=64), turn_key TEXT NOT NULL REFERENCES turns(turn_key) ON DELETE CASCADE, + phase TEXT NOT NULL CHECK(phase IN ('prepared','executing','settled','indeterminate','quarantined')), + identity_digest TEXT NOT NULL CHECK(length(identity_digest)=64), descriptor_ciphertext TEXT NOT NULL, descriptor_key_id TEXT NOT NULL, + reconcile_ciphertext TEXT, reconcile_key_id TEXT, prepared_at INTEGER NOT NULL, executing_at INTEGER, terminal_at INTEGER, byte_count INTEGER NOT NULL CHECK(byte_count>=0) +) STRICT; +CREATE INDEX operations_session_phase ON operations(session_key,phase); +CREATE TABLE control_receipts ( + receipt_key TEXT PRIMARY KEY CHECK(length(receipt_key)=64), session_key TEXT NOT NULL CHECK(length(session_key)=64), turn_key TEXT NOT NULL REFERENCES turns(turn_key) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('ask','answer','steer','cancel','transcript')), + phase TEXT NOT NULL CHECK(phase IN ('prepared','settled','indeterminate','quarantined')), + identity_digest TEXT NOT NULL CHECK(length(identity_digest)=64), descriptor_ciphertext TEXT, descriptor_key_id TEXT, + reconcile_ciphertext TEXT, reconcile_key_id TEXT, created_at INTEGER NOT NULL, terminal_at INTEGER, byte_count INTEGER NOT NULL CHECK(byte_count>=0) +) STRICT; +CREATE INDEX controls_session_phase ON control_receipts(session_key,phase); +CREATE TABLE outbox ( + outbox_key TEXT PRIMARY KEY CHECK(length(outbox_key)=64), session_key TEXT NOT NULL CHECK(length(session_key)=64), turn_key TEXT NOT NULL REFERENCES turns(turn_key) ON DELETE CASCADE, + phase TEXT NOT NULL CHECK(phase IN ('queued','claimed','acked','failed','quarantined')), + destination_digest TEXT NOT NULL CHECK(length(destination_digest)=64), body_digest TEXT NOT NULL CHECK(length(body_digest)=64), body_ciphertext TEXT, body_key_id TEXT, + created_at INTEGER NOT NULL, claimed_at INTEGER, terminal_at INTEGER, attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts BETWEEN 0 AND 64), byte_count INTEGER NOT NULL CHECK(byte_count>=0) +) STRICT; +CREATE INDEX outbox_claim ON outbox(phase,created_at); +CREATE TABLE quarantine_evidence ( + evidence_key TEXT PRIMARY KEY CHECK(length(evidence_key)=64), session_key TEXT CHECK(session_key IS NULL OR length(session_key)=64), turn_key TEXT REFERENCES turns(turn_key) ON DELETE CASCADE, + phase TEXT NOT NULL CHECK(phase IN ('quarantined','terminal')), evidence_ciphertext TEXT NOT NULL, evidence_key_id TEXT NOT NULL, + created_at INTEGER NOT NULL, terminal_at INTEGER NOT NULL, byte_count INTEGER NOT NULL CHECK(byte_count>=0) +) STRICT; +CREATE INDEX evidence_expiry ON quarantine_evidence(terminal_at); +CREATE TABLE deletion_tombstones ( + session_key TEXT PRIMARY KEY CHECK(length(session_key)=64), deleted_at INTEGER NOT NULL, expires_at INTEGER NOT NULL CHECK(expires_at>=deleted_at) +) STRICT; +CREATE INDEX tombstone_expiry ON deletion_tombstones(expires_at); +CREATE TABLE migration_history (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, digest TEXT NOT NULL CHECK(length(digest)=64)) STRICT; +`; + +const REQUIRED_OBJECTS = [ + "accounting", + "control_receipts", + "controls_session_phase", + "deletion_tombstones", + "evidence_expiry", + "migration_history", + "meta", + "operations", + "operations_session_phase", + "outbox", + "outbox_claim", + "quarantine_evidence", + "tombstone_expiry", + "turns", + "turns_phase_time", + "turns_session", + "writer", +].sort(); + +export function initializeExactLedgerSchema( + db: Database, + nowMs: number, + schemaDigest: string, +): void { + const version = db + .query<{ user_version: number }, []>("PRAGMA user_version") + .get()!.user_version; + const objects = db + .query<{ name: string }, []>( + "SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .all(); + if (version === 0 && objects.length === 0) { + db.exec("BEGIN IMMEDIATE"); + try { + db.exec(AGENT_HOST_LEDGER_SCHEMA_SQL); + db.query( + "INSERT INTO meta(singleton,schema_version,created_at) VALUES(1,1,?)", + ).run(nowMs); + db.exec( + "INSERT INTO accounting(singleton,global_charge,physical_high_water) VALUES(1,0,0)", + ); + db.query( + "INSERT INTO migration_history(version,applied_at,digest) VALUES(1,?,?)", + ).run(nowMs, schemaDigest); + db.exec(`PRAGMA user_version=${AGENT_HOST_LEDGER_SCHEMA_VERSION}`); + db.exec("COMMIT"); + } catch (error) { + try { + db.exec("ROLLBACK"); + } catch {} + throw error; + } + } else if (version !== AGENT_HOST_LEDGER_SCHEMA_VERSION) { + throw new Error(`unsupported Agent Host ledger schema ${version}`); + } + const actual = db + .query<{ name: string }, []>( + "SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .all() + .map((row) => row.name); + if ( + actual.length !== REQUIRED_OBJECTS.length || + actual.some((name, index) => name !== REQUIRED_OBJECTS[index]) + ) + throw new Error("Agent Host ledger schema residue or missing object"); + const meta = db + .query<{ schema_version: number }, []>( + "SELECT schema_version FROM meta WHERE singleton=1", + ) + .get(); + if (!meta || meta.schema_version !== AGENT_HOST_LEDGER_SCHEMA_VERSION) + throw new Error("Agent Host ledger metadata mismatch"); +} diff --git a/packages/core/opensession-server/src/agent-host/main.ts b/packages/core/opensession-server/src/agent-host/main.ts new file mode 100644 index 0000000000..ead49cd49f --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/main.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env bun +import { runAgentHost } from "./runtime"; + +export interface AgentHostArguments { + readonly generation: string; + readonly expectedGatewayUid: number; + readonly expectedHostUid: number; + readonly doctor: boolean; +} + +export function parseAgentHostArguments(argv: readonly string[]): AgentHostArguments { + let generation: string | undefined; + let expectedGatewayUid: number | undefined; + let expectedHostUid: number | undefined; + let doctor = false; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--doctor" && !doctor) doctor = true; + else if (argument === "--generation" && generation === undefined) generation = argv[++index]; + else if (argument === "--expected-gateway-uid" && expectedGatewayUid === undefined) expectedGatewayUid = Number(argv[++index]); + else if (argument === "--expected-host-uid" && expectedHostUid === undefined) expectedHostUid = Number(argv[++index]); + else throw new Error("Invalid Agent Host arguments"); + } + if (!generation || !Number.isSafeInteger(expectedGatewayUid) || expectedGatewayUid! <= 0 || + !Number.isSafeInteger(expectedHostUid) || expectedHostUid! <= 0) + throw new Error("Agent Host generation and service UIDs are required"); + return Object.freeze({ + generation, + expectedGatewayUid: expectedGatewayUid!, + expectedHostUid: expectedHostUid!, + doctor, + }); +} + +export async function main(argv: readonly string[] = process.argv.slice(2)): Promise { + await runAgentHost(parseAgentHostArguments(argv)); +} + +if (import.meta.main) { + main().catch(() => { + // Startup fails closed. Credential values and nested causes are never logged. + console.error("Open Session Agent Host startup failed"); + process.exit(1); + }); +} diff --git a/packages/core/opensession-server/src/agent-host/registry.test.ts b/packages/core/opensession-server/src/agent-host/registry.test.ts new file mode 100644 index 0000000000..0b8463961c --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/registry.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { AgentHostRegistry } from "../server/agent-host-registry"; + +const fence = { + sessionId: "session", + runId: "run", + turnId: "turn", + generation: 4, +}; + +describe("AgentHostRegistry", () => { + test("registers, finds, and unregisters exact fenced ownership", () => { + const registry = new AgentHostRegistry(); + const owner = {}; + registry.register(fence, owner); + expect(registry.find(fence)).toBe(owner); + expect(registry.find({ ...fence, generation: 3 })).toBeUndefined(); + expect(registry.unregister({ ...fence, generation: 3 })).toBe(false); + expect(registry.unregister(fence, {})).toBe(false); + expect(registry.unregister(fence, owner)).toBe(true); + expect(registry.find(fence)).toBeUndefined(); + }); + + test("rejects duplicate and stale ownership", () => { + const registry = new AgentHostRegistry(); + registry.register(fence, "owner"); + expect(() => registry.register(fence, "duplicate")).toThrow("duplicate"); + expect(() => + registry.register({ ...fence, generation: 3 }, "stale"), + ).toThrow("stale"); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/runtime.test.ts b/packages/core/opensession-server/src/agent-host/runtime.test.ts new file mode 100644 index 0000000000..f0a3603e01 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/runtime.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { assertInheritedUnixListenerDescriptor } from "../server/security/transport/unix-socket-security"; +import { parseAgentHostArguments } from "./main"; +import { + decodeHostLedgerCredential, + generationLedgerPath, + inheritedActivationFd, + installBoundedSignalDrain, + readSystemdCredential, +} from "./runtime"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +async function root() { + const path = await mkdtemp(join(tmpdir(), "agent-host-runtime-")); + roots.push(path); + return path; +} + +describe("detached Agent Host runtime", () => { + test("entrypoint import is inert", async () => { + const entry = new URL("./main.ts", import.meta.url).href; + const child = Bun.spawn([process.execPath, "-e", `await import(${JSON.stringify(entry)}); console.log("inert")`], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, CREDENTIALS_DIRECTORY: "", LISTEN_FDS: "" }, + }); + expect(await child.exited).toBe(0); + expect(await new Response(child.stdout).text()).toBe("inert\n"); + }); + + test("requires exact service identities and named socket activation", () => { + expect(parseAgentHostArguments([ + "--doctor", "--generation", "7", "--expected-gateway-uid", "12345", "--expected-host-uid", "12346", + ])).toEqual({ generation: "7", expectedGatewayUid: 12345, expectedHostUid: 12346, doctor: true }); + expect(() => parseAgentHostArguments(["--generation", "7", "--expected-gateway-uid", "12345"])).toThrow(); + expect(inheritedActivationFd({ LISTEN_PID: "42", LISTEN_FDS: "1", LISTEN_FDNAMES: "agent-host" }, 42)).toBe(3); + expect(() => inheritedActivationFd({ LISTEN_PID: "42", LISTEN_FDS: "2", LISTEN_FDNAMES: "agent-host" }, 42)).toThrow(); + }); + + test("proves an inherited descriptor is a listening AF_UNIX socket", async () => { + const directory = await root(); + const path = join(directory, "listener.sock"); + const server = createServer(); + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(path, resolveListen); + }); + const fd = (server as unknown as { _handle: { fd: number } })._handle.fd; + await expect(assertInheritedUnixListenerDescriptor(fd)).resolves.toBeUndefined(); + await new Promise((resolveClose) => server.close(() => resolveClose())); + }); + + test("rejects malformed, redirected, and loose credential files", async () => { + expect(() => decodeHostLedgerCredential({ version: 1, activeKeyId: "x", keys: [] })).toThrow(); + const directory = await root(); + const credential = join(directory, "credential"); + await writeFile(credential, "{}", { mode: 0o600 }); + await expect(readSystemdCredential("credential", directory, process.getuid!())).rejects.toThrow("mode validation"); + await chmod(credential, 0o400); + expect(await readSystemdCredential("credential", directory, process.getuid!())).toEqual({}); + const redirected = join(directory, "redirected"); + await symlink(credential, redirected); + await expect(readSystemdCredential("redirected", directory, process.getuid!())).rejects.toThrow(); + }); + + test("isolates the ledger path to the exact generation StateDirectory", async () => { + const parent = await root(); + const one = join(parent, "1"); + const two = join(parent, "2"); + await Promise.all([mkdir(one, { mode: 0o700 }), mkdir(two, { mode: 0o700 })]); + expect(await generationLedgerPath("1", one)).toBe(join(one, "recovery-ledger.sqlite")); + expect(await generationLedgerPath("2", two)).toBe(join(two, "recovery-ledger.sqlite")); + await expect(generationLedgerPath("1", two)).rejects.toThrow(); + await chmod(one, 0o750); + await expect(generationLedgerPath("1", one)).rejects.toThrow("mode validation"); + }); + + test("SIGTERM drain is idempotent and bounded", async () => { + let drains = 0; + let code: number | undefined; + const signal = installBoundedSignalDrain(async () => { drains++; }, (value) => { code = value; }, 100); + signal(); + signal(); + await Bun.sleep(10); + expect(drains).toBe(1); + expect(code).toBe(0); + + let timeoutCode: number | undefined; + installBoundedSignalDrain(() => new Promise(() => {}), (value) => { timeoutCode = value; }, 5)(); + await Bun.sleep(15); + expect(timeoutCode).toBe(1); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/runtime.ts b/packages/core/opensession-server/src/agent-host/runtime.ts new file mode 100644 index 0000000000..058d44a775 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/runtime.ts @@ -0,0 +1,201 @@ +import { lstat, readFile, realpath } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { decodeAgentHostSupervisionPublicKeyringV2 } from "@tellahq/opensession-protocol"; +import type { AgentTurnDriver } from "./driver"; +import { createAgentHost } from "./host"; +import type { HostLedgerKeyringInput } from "./ledger-crypto"; +import { SQLiteHostRecoveryLedger } from "./sqlite-ledger"; +import { assertInheritedUnixListenerDescriptor } from "../server/security/transport/unix-socket-security"; + +export const AGENT_HOST_MAX_GENERATION_LIFETIME_MS = 24 * 60 * 60 * 1000; +export const AGENT_HOST_DRAIN_TIMEOUT_MS = 15_000; +const CREDENTIAL_LIMIT = 64 * 1024; +const GENERATION = /^[1-9][0-9]{0,9}$/; +const B64URL = /^[A-Za-z0-9_-]+$/; + +export interface AgentHostRuntimeOptions { + readonly generation: string; + readonly expectedGatewayUid: number; + readonly expectedHostUid: number; + readonly doctor?: boolean; + readonly now?: () => number; +} + +function exactRecord(value: unknown, keys: readonly string[]): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) && + Object.keys(value).length === keys.length && Object.keys(value).every((key) => keys.includes(key)); +} + +function decodeSecret(value: unknown, minimum: number, exact?: number): Uint8Array | undefined { + if (typeof value !== "string" || !B64URL.test(value) || value.includes("=")) return undefined; + const bytes = Buffer.from(value, "base64url"); + if (bytes.toString("base64url") !== value || bytes.byteLength < minimum || (exact !== undefined && bytes.byteLength !== exact)) return undefined; + return bytes; +} + +export function decodeHostLedgerCredential(value: unknown): HostLedgerKeyringInput { + if (!exactRecord(value, ["version", "activeKeyId", "keys"]) || value.version !== 1 || + typeof value.activeKeyId !== "string" || !Array.isArray(value.keys) || value.keys.length < 1 || value.keys.length > 4) + throw new Error("Malformed Agent Host ledger credential"); + const keys: HostLedgerKeyringInput["keys"][number][] = []; + const seen = new Set(); + for (const candidate of value.keys) { + if (!exactRecord(candidate, ["id", "encryptionKey", "lookupKey", "decryptNotBeforeMs", "decryptNotAfterMs"]) || + typeof candidate.id !== "string" || seen.has(candidate.id) || + !Number.isSafeInteger(candidate.decryptNotBeforeMs) || !Number.isSafeInteger(candidate.decryptNotAfterMs)) + throw new Error("Malformed Agent Host ledger credential"); + const encryptionKey = decodeSecret(candidate.encryptionKey, 32, 32); + const lookupKey = decodeSecret(candidate.lookupKey, 32); + if (!encryptionKey || !lookupKey) throw new Error("Malformed Agent Host ledger credential"); + seen.add(candidate.id); + keys.push({ + id: candidate.id, + encryptionKey, + lookupKey, + decryptNotBeforeMs: candidate.decryptNotBeforeMs as number, + decryptNotAfterMs: candidate.decryptNotAfterMs as number, + }); + } + return { activeKeyId: value.activeKeyId, keys, maxOldKeys: 3 }; +} + +export async function readSystemdCredential( + name: string, + directory = process.env.CREDENTIALS_DIRECTORY, + expectedOwnerUid = 0, +): Promise { + if (!directory || !isAbsolute(directory) || resolve(directory) !== directory) + throw new Error("Systemd credential directory is unavailable"); + const path = join(directory, name); + const [directoryReal, stat] = await Promise.all([realpath(directory), lstat(path)]); + if (dirname(await realpath(path)) !== directoryReal || !stat.isFile() || stat.isSymbolicLink() || + stat.uid !== expectedOwnerUid || (stat.mode & 0o7777) !== 0o400 || stat.nlink !== 1 || stat.size < 2 || stat.size > CREDENTIAL_LIMIT) + throw new Error(`Systemd credential ${name} failed ownership or mode validation`); + try { + return JSON.parse(await readFile(path, "utf8")); + } catch { + throw new Error(`Systemd credential ${name} is malformed`); + } +} + +interface SocketActivationEnvironment { + LISTEN_PID?: string; + LISTEN_FDS?: string; + LISTEN_FDNAMES?: string; +} + +export function inheritedActivationFd( + env?: SocketActivationEnvironment, + pid = process.pid, +): number { + const activation = env ?? process.env; + if (activation.LISTEN_PID !== String(pid) || activation.LISTEN_FDS !== "1" || activation.LISTEN_FDNAMES !== "agent-host") + throw new Error("Exactly one named systemd Agent Host socket is required"); + return 3; +} + +export async function generationLedgerPath(generation: string, stateDirectory = process.env.STATE_DIRECTORY): Promise { + if (!GENERATION.test(generation) || !stateDirectory || !isAbsolute(stateDirectory) || resolve(stateDirectory) !== stateDirectory || basename(stateDirectory) !== generation) + throw new Error("Agent Host generation StateDirectory is invalid"); + const stat = await lstat(stateDirectory); + const uid = process.getuid?.(); + if (!stat.isDirectory() || stat.isSymbolicLink() || uid === undefined || stat.uid !== uid || (stat.mode & 0o7777) !== 0o700) + throw new Error("Agent Host generation StateDirectory failed ownership or mode validation"); + return join(stateDirectory, "recovery-ledger.sqlite"); +} + +function unavailableDriver(): AgentTurnDriver { + return { + async run() { return { status: "failed", error: "Agent Host production routing is not activated" }; }, + async deliverOperationStream() { throw new Error("Agent Host production routing is not activated"); }, + async cancel() {}, + async shutdown() {}, + }; +} + +export function installBoundedSignalDrain( + drain: () => Promise, + exit: (code: number) => void = (code) => process.exit(code), + timeoutMs = AGENT_HOST_DRAIN_TIMEOUT_MS, +): () => void { + let draining = false; + return () => { + if (draining) return; + draining = true; + let timer: ReturnType | undefined; + void Promise.race([ + drain(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Agent Host drain timed out")), timeoutMs); + timer.unref?.(); + }), + ]).then(() => exit(0), () => exit(1)).finally(() => { if (timer) clearTimeout(timer); }); + }; +} + +export async function runAgentHost(options: AgentHostRuntimeOptions): Promise { + if ( + !GENERATION.test(options.generation) || + !Number.isSafeInteger(options.expectedGatewayUid) || + options.expectedGatewayUid <= 0 || + !Number.isSafeInteger(options.expectedHostUid) || + options.expectedHostUid <= 0 || + process.getuid?.() !== options.expectedHostUid || + options.expectedHostUid === options.expectedGatewayUid + ) + throw new Error("Invalid Agent Host generation or service UID boundary"); + const fd = inheritedActivationFd(); + await assertInheritedUnixListenerDescriptor(fd); + const [ledgerValue, supervisionValue, dbPath] = await Promise.all([ + readSystemdCredential("agent-host-ledger-keyring"), + readSystemdCredential("agent-host-supervision-keyring"), + generationLedgerPath(options.generation), + ]); + const ledgerKeyring = decodeHostLedgerCredential(ledgerValue); + const supervisionKeyring = decodeAgentHostSupervisionPublicKeyringV2(supervisionValue); + if (!supervisionKeyring) throw new Error("Malformed Agent Host supervision public keyring credential"); + let ledger: SQLiteHostRecoveryLedger; + try { + ledger = new SQLiteHostRecoveryLedger({ + dbPath, + keyring: ledgerKeyring, + writerNonce: crypto.randomUUID(), + now: options.now, + }); + } finally { + for (const key of ledgerKeyring.keys) { + key.encryptionKey.fill(0); + key.lookupKey.fill(0); + } + } + if (options.doctor) { + ledger.close(); + return; + } + let host; + try { + host = createAgentHost({ + inheritedFd: fd, + expectedPeerUid: options.expectedGatewayUid, + createDriver: unavailableDriver, + hostId: `agent-host-${options.generation}`, + hostGeneration: Number(options.generation), + hostIncarnation: crypto.randomUUID(), + supervisionKeyring, + }); + await host.start(); + } catch (error) { + ledger.close(); + throw error; + } + let lifetime: ReturnType | undefined; + const drain = installBoundedSignalDrain(async () => { + if (lifetime) clearTimeout(lifetime); + await host.stop(); + ledger.close(); + }); + process.once("SIGTERM", drain); + process.once("SIGINT", drain); + lifetime = setTimeout(drain, AGENT_HOST_MAX_GENERATION_LIFETIME_MS); + lifetime.unref?.(); +} diff --git a/packages/core/opensession-server/src/agent-host/socket-framing.test.ts b/packages/core/opensession-server/src/agent-host/socket-framing.test.ts new file mode 100644 index 0000000000..537bd45d0d --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/socket-framing.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { + BoundedNdjsonDecoder, + NdjsonFrameError, + encodeNdjsonFrame, +} from "./socket-framing"; + +describe("bounded NDJSON framing", () => { + test("frames split multibyte input by bytes", () => { + const decoder = new BoundedNdjsonDecoder(32); + const frame = encodeNdjsonFrame({ text: "🌊" }, 32); + expect(decoder.push(frame.subarray(0, frame.byteLength - 2))).toEqual([]); + expect(decoder.push(frame.subarray(frame.byteLength - 2))).toEqual([ + { text: "🌊" }, + ]); + }); + + test("fails closed on malformed and oversized frames", () => { + const malformed = new BoundedNdjsonDecoder(8); + expect(() => malformed.push(Buffer.from("nope\n"))).toThrow( + NdjsonFrameError, + ); + expect(() => malformed.push(Buffer.from("{}\n"))).toThrow(NdjsonFrameError); + const oversized = new BoundedNdjsonDecoder(2); + expect(() => oversized.push(Buffer.from("123"))).toThrow("exceeds 2 bytes"); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/socket-framing.ts b/packages/core/opensession-server/src/agent-host/socket-framing.ts new file mode 100644 index 0000000000..627cd3a4c0 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/socket-framing.ts @@ -0,0 +1,93 @@ +const DEFAULT_MAX_FRAME_BYTES = 1024 * 1024; +const newline = 0x0a; +const carriageReturn = 0x0d; +const utf8 = new TextDecoder("utf-8", { fatal: true }); + +export class NdjsonFrameError extends Error { + constructor(message: string) { + super(message); + this.name = "NdjsonFrameError"; + } +} + +/** Incremental, byte-bounded NDJSON decoder. Once a frame fails, the decoder + * stays failed so callers cannot accidentally continue on a compromised stream. */ +export class BoundedNdjsonDecoder { + private buffered = Buffer.alloc(0); + private failed = false; + + constructor(readonly maxFrameBytes = DEFAULT_MAX_FRAME_BYTES) { + if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes < 1) { + throw new RangeError("maxFrameBytes must be a positive safe integer"); + } + } + + push(chunk: Uint8Array): unknown[] { + if (this.failed) + throw new NdjsonFrameError("decoder is closed after a framing error"); + if (chunk.byteLength === 0) return []; + + const messages: unknown[] = []; + let start = 0; + for (let index = 0; index < chunk.byteLength; index += 1) { + if (chunk[index] !== newline) continue; + this.append(chunk.subarray(start, index)); + messages.push(this.decodeBuffered()); + start = index + 1; + } + this.append(chunk.subarray(start)); + return messages; + } + + finish(): void { + if (this.failed) return; + if (this.buffered.byteLength !== 0) this.fail("unterminated NDJSON frame"); + } + + private append(chunk: Uint8Array): void { + if (this.buffered.byteLength + chunk.byteLength > this.maxFrameBytes) { + this.fail(`NDJSON frame exceeds ${this.maxFrameBytes} bytes`); + } + if (chunk.byteLength === 0) return; + this.buffered = Buffer.concat([this.buffered, Buffer.from(chunk)]); + } + + private decodeBuffered(): unknown { + let frame = this.buffered; + this.buffered = Buffer.alloc(0); + if (frame.at(-1) === carriageReturn) frame = frame.subarray(0, -1); + if (frame.byteLength === 0) this.fail("empty NDJSON frame"); + try { + return JSON.parse(utf8.decode(frame)); + } catch { + return this.fail("malformed NDJSON frame"); + } + } + + private fail(message: string): never { + this.failed = true; + this.buffered = Buffer.alloc(0); + throw new NdjsonFrameError(message); + } +} + +export function encodeNdjsonFrame( + value: unknown, + maxFrameBytes = DEFAULT_MAX_FRAME_BYTES, +): Buffer { + let json: string; + try { + json = JSON.stringify(value); + } catch { + throw new NdjsonFrameError("message is not JSON serializable"); + } + if (json === undefined) + throw new NdjsonFrameError("message is not JSON serializable"); + const frame = Buffer.from(`${json}\n`, "utf8"); + if (frame.byteLength - 1 > maxFrameBytes) { + throw new NdjsonFrameError(`NDJSON frame exceeds ${maxFrameBytes} bytes`); + } + return frame; +} + +export const AGENT_HOST_MAX_FRAME_BYTES = DEFAULT_MAX_FRAME_BYTES; diff --git a/packages/core/opensession-server/src/agent-host/sqlite-ledger.test.ts b/packages/core/opensession-server/src/agent-host/sqlite-ledger.test.ts new file mode 100644 index 0000000000..f574a50c98 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/sqlite-ledger.test.ts @@ -0,0 +1,325 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { + mkdtempSync, + readFileSync, + rmSync, + statSync, + truncateSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { AGENT_HOST_LEDGER_RETENTION_MS } from "./ledger-schema"; +import { + HostLedgerConflictError, + HostLedgerDeletedError, + SQLiteHostRecoveryLedger, + type TurnFence, +} from "./sqlite-ledger"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); +}); +function fixture() { + const dir = mkdtempSync(join(tmpdir(), "host-ledger-")); + dirs.push(dir); + const dbPath = join(dir, "recovery.sqlite"); + let now = 1000; + const options = { + dbPath, + writerNonce: "writer-nonce-0001", + now: () => now, + emergencyReserveBytes: 4 * 1024 * 1024, + keyring: { + activeKeyId: "active", + keys: [ + { + id: "active", + encryptionKey: new Uint8Array(32).fill(4), + lookupKey: new Uint8Array(32).fill(9), + decryptNotBeforeMs: 0, + decryptNotAfterMs: Number.MAX_SAFE_INTEGER, + }, + ], + }, + }; + return { + dbPath, + options, + setNow: (v: number) => (now = v), + open: () => new SQLiteHostRecoveryLedger(options), + }; +} +const fence = (n = 1): TurnFence => ({ + sessionId: `secret-session-${n}`, + runId: `secret-run-${n}`, + turnId: `secret-turn-${n}`, + generation: 1, +}); +const digest = (n: number) => n.toString(16).padStart(64, "0"); + +describe("SQLite Host recovery ledger", () => { + test("creates exact STRICT private WAL schema without plaintext identifiers", () => { + const f = fixture(), + ledger = f.open(); + ledger.admitTurn({ + fence: fence(), + authorityHash: digest(1), + recoveryDescriptor: { mode: "secret-recovery-mode" }, + admittedAtMs: 1000, + }); + const bytes = Buffer.concat([ + readFileSync(f.dbPath), + readFileSync(`${f.dbPath}-wal`), + ]).toString("utf8"); + expect(bytes).not.toContain("secret-session"); + expect(bytes).not.toContain("secret-run"); + expect(bytes).not.toContain("secret-turn"); + expect(bytes).not.toContain("secret-recovery-mode"); + expect(statSync(f.dbPath).mode & 0o777).toBe(0o600); + expect(statSync(`${f.dbPath}-wal`).mode & 0o777).toBe(0o600); + const db = new Database(f.dbPath, { readonly: true }); + expect( + db + .query<{ strict: number }, []>( + "SELECT strict FROM pragma_table_list WHERE name='turns'", + ) + .get()!.strict, + ).toBe(1); + db.close(); + ledger.close(); + }); + test("supports many lightweight turns with no count cap and exact replay", () => { + const f = fixture(), + ledger = f.open(); + for (let n = 1; n <= 80; n++) { + expect( + ledger.admitTurn({ + fence: fence(n), + authorityHash: digest(1), + recoveryDescriptor: { n }, + admittedAtMs: 1000, + }).admitted, + ).toBe(true); + } + expect( + ledger.admitTurn({ + fence: fence(80), + authorityHash: digest(1), + recoveryDescriptor: { n: 80 }, + admittedAtMs: 1000, + }).admitted, + ).toBe(false); + ledger.close(); + }); + test("enforces operation transitions, mismatches, and executing recovery", () => { + const f = fixture(), + ledger = f.open(), + turn = fence(); + ledger.admitTurn({ + fence: turn, + authorityHash: digest(1), + recoveryDescriptor: { a: 1 }, + admittedAtMs: 1000, + }); + const op = { + fence: turn, + operationId: "secret-operation", + identityDigest: digest(2), + reconcileRef: { opaque: "r" }, + atMs: 1000, + }; + expect(ledger.prepareOperation(op).prepared).toBe(true); + expect(ledger.prepareOperation(op).prepared).toBe(false); + expect(() => + ledger.prepareOperation({ ...op, identityDigest: digest(3) }), + ).toThrow(HostLedgerConflictError); + ledger.close(); + const f2 = fixture(), + l2 = f2.open(), + turn2 = fence(2), + op2 = { + fence: turn2, + operationId: "op2", + identityDigest: digest(2), + reconcileRef: { x: 1 }, + atMs: 1000, + }; + l2.admitTurn({ + fence: turn2, + authorityHash: digest(1), + recoveryDescriptor: {}, + admittedAtMs: 1000, + }); + l2.prepareOperation(op2); + l2.markExecuting(op2); + expect(l2.scanRecover()).toContainEqual( + expect.objectContaining({ phase: "indeterminate", replayable: false }), + ); + l2.close(); + }); + test("records controls and erases temporary outbox bytes only after destination ack", () => { + const f = fixture(), + ledger = f.open(), + turn = fence(); + ledger.admitTurn({ + fence: turn, + authorityHash: digest(1), + recoveryDescriptor: {}, + admittedAtMs: 1000, + }); + const control = { + fence: turn, + receiptId: "ask-1", + kind: "ask" as const, + identityDigest: digest(4), + reconcileRef: { ref: "opaque" }, + atMs: 1000, + }; + expect(ledger.recordControl(control).recorded).toBe(true); + ledger.settleControl(control, { done: true }); + ledger.enqueueOutbox({ + fence: turn, + outboxId: "append-1", + destinationDigest: digest(5), + temporaryBody: new TextEncoder().encode("temporary model-visible body"), + atMs: 1000, + }); + const claimed = ledger.claimOutbox(); + expect(new TextDecoder().decode(claimed[0]!.temporaryBody)).toBe( + "temporary model-visible body", + ); + ledger.ackOutbox(claimed[0]!.outboxKey, 1001); + const db = new Database(f.dbPath, { readonly: true }); + expect( + db + .query<{ body_ciphertext: string | null; byte_count: number }, []>( + "SELECT body_ciphertext,byte_count FROM outbox", + ) + .get(), + ).toEqual({ body_ciphertext: null, byte_count: 0 }); + db.close(); + ledger.close(); + }); + test("authoritative deletion atomically purges and tombstone fences late writes", () => { + const f = fixture(), + ledger = f.open(), + turn = fence(); + ledger.admitTurn({ + fence: turn, + authorityHash: digest(1), + recoveryDescriptor: {}, + admittedAtMs: 1000, + }); + expect(ledger.deleteSession(turn.sessionId, 1001)).toBe(1); + expect(() => + ledger.admitTurn({ + fence: { ...turn, turnId: "late" }, + authorityHash: digest(1), + recoveryDescriptor: {}, + admittedAtMs: 1002, + }), + ).toThrow(HostLedgerDeletedError); + ledger.close(); + }); + test("uses an exact seven-day expiry boundary", () => { + const f = fixture(), + ledger = f.open(), + turn = fence(); + ledger.admitTurn({ + fence: turn, + authorityHash: digest(1), + recoveryDescriptor: {}, + admittedAtMs: 1000, + }); + ledger.quarantine(turn, "evidence", { reason: "proof" }, 1000); + expect(ledger.purgeExpired(1000 + AGENT_HOST_LEDGER_RETENTION_MS)).toBe(0); + expect( + ledger.purgeExpired(1001 + AGENT_HOST_LEDGER_RETENTION_MS), + ).toBeGreaterThan(0); + ledger.close(); + }); + test("recovers a partially consumed reserve on reopen", () => { + const f = fixture(); + f.open().close(); + const reservePath = join( + dirname(f.dbPath), + ".agent-host-emergency.reserve", + ); + truncateSync(reservePath, 1024 * 1024); + const reopened = f.open(); + const reserve = statSync(reservePath); + expect(reserve.size).toBe(4 * 1024 * 1024); + expect(Number(reserve.blocks) * 512).toBeGreaterThanOrEqual(reserve.size); + reopened.close(); + }); + test("reconciles every injected emergency fault without physical retries", () => { + const boundaries = [ + "reserve:before-consume", + "reserve:after-consume", + "transaction:before-begin", + "transaction:after-begin", + "transaction:before-commit", + "transaction:after-commit", + "checkpoint:before", + "checkpoint:after", + "reserve:before-recreate", + "reserve:after-recreate", + ] as const; + for (const boundary of boundaries) { + const f = fixture(); + let armed = false; + let hits = 0; + const ledger = new SQLiteHostRecoveryLedger({ + ...f.options, + injectFault(at) { + if (armed && at === boundary) { + hits++; + throw new Error(`fault:${boundary}`); + } + }, + }); + const turn = fence(); + ledger.admitTurn({ + fence: turn, + authorityHash: digest(1), + recoveryDescriptor: {}, + admittedAtMs: 1000, + }); + armed = true; + expect(() => ledger.quarantine(turn, "fault", {}, 1001)).toThrow( + `fault:${boundary}`, + ); + expect(hits).toBe(1); + ledger.close(); + const reopened = f.open(); + const reserve = statSync( + join(dirname(f.dbPath), ".agent-host-emergency.reserve"), + ); + expect(reserve.size).toBe(4 * 1024 * 1024); + expect(Number(reserve.blocks) * 512).toBeGreaterThanOrEqual(reserve.size); + reopened.close(); + } + }); + test("reopens with stable writer nonce and validates AEAD", () => { + const f = fixture(); + f.open().close(); + const ledger = f.open(); + ledger.admitTurn({ + fence: fence(), + authorityHash: digest(1), + recoveryDescriptor: {}, + admittedAtMs: 1000, + }); + ledger.close(); + expect( + () => + new SQLiteHostRecoveryLedger({ + ...f.options, + writerNonce: "different-writer-0002", + }), + ).not.toThrow(); + }); +}); diff --git a/packages/core/opensession-server/src/agent-host/sqlite-ledger.ts b/packages/core/opensession-server/src/agent-host/sqlite-ledger.ts new file mode 100644 index 0000000000..b71f1f9030 --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/sqlite-ledger.ts @@ -0,0 +1,1736 @@ +import { Database } from "bun:sqlite"; +import { createHash } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + lstatSync, + mkdirSync, + openSync, +} from "node:fs"; +import { dirname, parse, resolve } from "node:path"; +import { + GenerationEmergencyReserve, + type GenerationOwner, +} from "./emergency-reserve"; +import { + HostLedgerKeyring, + type HostLedgerKeyringInput, +} from "./ledger-crypto"; +import { + assertCommittedBound, + preflightLiability, + nodeLedgerPhysicalAccounting, + type LedgerPhysicalAccounting, + type LedgerWriteClass, + type WriteShape, +} from "./ledger-accounting"; +import { + AGENT_HOST_LEDGER_RETENTION_MS, + AGENT_HOST_LEDGER_SCHEMA_SQL, + initializeExactLedgerSchema, +} from "./ledger-schema"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true }); +const DIGEST = /^[a-f0-9]{64}$/; +const PHASE_TERMINAL = new Set([ + "settled", + "terminal", + "indeterminate", + "quarantined", + "acked", + "failed", +]); +type Plain = + null | boolean | number | string | Plain[] | { [key: string]: Plain }; +export interface TurnFence { + sessionId: string; + runId: string; + turnId: string; + generation: number; +} +export interface AdmitTurnInput { + fence: TurnFence; + authorityHash: string; + recoveryDescriptor: Plain; + admittedAtMs: number; +} +export interface OperationInput { + fence: TurnFence; + operationId: string; + identityDigest: string; + reconcileRef: Plain; + atMs: number; +} +export interface ControlInput { + fence: TurnFence; + receiptId: string; + kind: "ask" | "answer" | "steer" | "cancel" | "transcript"; + identityDigest: string; + reconcileRef?: Plain; + atMs: number; +} +export interface OutboxInput { + fence: TurnFence; + outboxId: string; + destinationDigest: string; + temporaryBody: Uint8Array; + atMs: number; +} +export interface PositiveGatewayReceiptProof { + readonly type: "positive_gateway_receipt_v1"; + readonly operationKey: string; + readonly identityDigest: string; + readonly receiptDigest: string; + readonly reconciliationRef: Plain; +} +export interface RecoveryRecord { + kind: "turn" | "operation" | "control" | "outbox"; + opaqueKey: string; + phase: string; + replayable: boolean; +} +export type LedgerFaultBoundary = + | "transaction:before-begin" + | "transaction:after-begin" + | "transaction:before-commit" + | "transaction:after-commit" + | "checkpoint:before" + | "checkpoint:after" + | "reserve:before-consume" + | "reserve:after-consume" + | "reserve:before-recreate" + | "reserve:after-recreate"; + +export interface SQLiteHostLedgerOptions { + dbPath: string; + keyring: HostLedgerKeyringInput; + writerNonce: string; + now?: () => number; + busyTimeoutMs?: number; + verifyPositiveGatewayReceiptProof?: ( + proof: PositiveGatewayReceiptProof, + ) => boolean; + /** Qualification seams. Production callers must use the defaults. */ + physicalAccounting?: LedgerPhysicalAccounting; + injectFault?: (boundary: LedgerFaultBoundary) => void; + /** Exact generation StateDirectory owner. Defaults to the current process. */ + generationOwner?: GenerationOwner; + /** Qualification seam. Production generations must retain the 64 MiB default. */ + emergencyReserveBytes?: number; +} +export class HostLedgerConflictError extends Error { + constructor(message = "Host ledger identity conflict") { + super(message); + this.name = "HostLedgerConflictError"; + } +} +export class HostLedgerDeletedError extends Error { + constructor() { + super("authoritatively deleted Host ledger session"); + this.name = "HostLedgerDeletedError"; + } +} +class CommitThenThrow extends Error { + constructor(readonly rejection: Error) { + super(rejection.message); + } +} + +function exactFence(fence: TurnFence): string { + if ( + !fence || + typeof fence !== "object" || + !fence.sessionId || + !fence.runId || + !fence.turnId || + !Number.isSafeInteger(fence.generation) || + fence.generation < 1 + ) + throw new Error("invalid exact turn fence"); + return JSON.stringify({ + sessionId: fence.sessionId, + runId: fence.runId, + turnId: fence.turnId, + generation: fence.generation, + }); +} +function canonical(value: Plain): string { + if (value === null || typeof value === "boolean" || typeof value === "string") + return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) + throw new Error("ledger values require safe integers"); + return String(value); + } + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (!value || Object.getPrototypeOf(value) !== Object.prototype) + throw new Error("ledger value must be plain JSON"); + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(value[key]!)}`) + .join(",")}}`; +} +const validTime = (value: number) => { + if (!Number.isSafeInteger(value) || value < 0) + throw new Error("invalid ledger timestamp"); +}; +const validDigest = (value: string) => { + if (!DIGEST.test(value)) throw new Error("invalid opaque digest"); +}; +const keyId = (envelope: string) => envelope.split(".")[1]!; + +export class SQLiteHostRecoveryLedger { + readonly #db: Database; + readonly #path: string; + readonly #keys: HostLedgerKeyring; + readonly #writerNonce: string; + readonly #now: () => number; + readonly #verifyPositiveProof?: ( + proof: PositiveGatewayReceiptProof, + ) => boolean; + readonly #physical: LedgerPhysicalAccounting; + readonly #injectFault: (boundary: LedgerFaultBoundary) => void; + readonly #reserve: GenerationEmergencyReserve; + #closed = false; + #activeLiability = 0; + + constructor(options: SQLiteHostLedgerOptions) { + this.#path = resolve(options.dbPath); + if ( + !options.dbPath || + options.dbPath === ":memory:" || + !/^[A-Za-z0-9._:-]{16,128}$/.test(options.writerNonce) + ) + throw new Error("invalid Host ledger path or writer nonce"); + this.#keys = new HostLedgerKeyring(options.keyring); + this.#writerNonce = options.writerNonce; + this.#now = options.now ?? Date.now; + this.#verifyPositiveProof = options.verifyPositiveGatewayReceiptProof; + this.#physical = options.physicalAccounting ?? nodeLedgerPhysicalAccounting; + this.#injectFault = options.injectFault ?? (() => {}); + preparePrivatePath(this.#path); + preflightSidecars(this.#path); + const existed = exists(this.#path); + const db = new Database(this.#path, { create: true, strict: true }); + let claimedWriter = false; + try { + db.exec( + `PRAGMA busy_timeout=${options.busyTimeoutMs ?? 5000}; PRAGMA page_size=4096; PRAGMA foreign_keys=ON;`, + ); + if ( + Number( + db.query<{ page_size: number }, []>("PRAGMA page_size").get()! + .page_size, + ) !== 4096 + ) + throw new Error("Host ledger page size is not 4096"); + initializeExactLedgerSchema( + db, + this.#now(), + createHash("sha256").update(AGENT_HOST_LEDGER_SCHEMA_SQL).digest("hex"), + ); + db.exec( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA wal_autocheckpoint=1000;", + ); + const integrity = db + .query<{ quick_check: string }, []>("PRAGMA quick_check") + .all(); + if (integrity.length !== 1 || integrity[0]!.quick_check !== "ok") + throw new Error("Host ledger structural corruption"); + this.#db = db; + this.#claimWriter(); + claimedWriter = true; + secureFiles(this.#path); + const reserve = new GenerationEmergencyReserve({ + stateDirectory: dirname(this.#path), + owner: options.generationOwner, + bytes: options.emergencyReserveBytes, + }); + this.#reserve = reserve; + try { + // A crash may leave released reserve blocks occupied by SQLite WAL. + // Recover/checkpoint first, then make one allocation pass. + this.#checkpoint(false); + reserve.replenish(); + } catch (error) { + reserve.close(); + throw error; + } + if (!existed) this.#updatePhysicalHighWater(); + this.validateEncryptedRows(); + } catch (error) { + if (claimedWriter) { + try { + db.query( + "DELETE FROM writer WHERE singleton=1 AND claim_nonce=? AND process_id=?", + ).run(this.#writerNonce, process.pid); + } catch {} + } + db.close(); + throw error; + } + } + + admitTurn(input: AdmitTurnInput): { admitted: boolean; turnKey: string } { + validTime(input.admittedAtMs); + validDigest(input.authorityHash); + const fenceText = exactFence(input.fence), + sessionKey = this.#id("session", input.fence.sessionId), + turnKey = this.#id("turn", input.fence.turnId), + runKey = this.#id("run", input.fence.runId); + const descriptor = encoder.encode(canonical(input.recoveryDescriptor)); + const authority = encoder.encode(input.authorityHash); + try { + return this.#write( + turnKey, + "ordinary", + { + encryptedPlaintextBytes: + descriptor.byteLength + + authority.byteLength + + encoder.encode(fenceText).byteLength, + rowsInserted: 1, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 3, + }, + () => { + this.#assertNotDeleted(sessionKey); + const row = this.#db + .query< + { + run_key: string; + fence_ciphertext: string; + authority_ciphertext: string; + }, + [string] + >( + "SELECT run_key,fence_ciphertext,authority_ciphertext FROM turns WHERE turn_key=?", + ) + .get(turnKey); + if (row) { + const storedFence = this.#decrypt( + row.fence_ciphertext, + "turns", + turnKey, + fenceText, + ); + const storedAuthority = this.#decrypt( + row.authority_ciphertext, + "turns", + turnKey, + fenceText, + ); + let mismatch: boolean; + try { + mismatch = + row.run_key !== runKey || + decoder.decode(storedFence) !== fenceText || + decoder.decode(storedAuthority) !== + canonical({ + authorityHash: input.authorityHash, + recoveryDescriptor: input.recoveryDescriptor, + }); + } finally { + storedFence.fill(0); + storedAuthority.fill(0); + } + if (mismatch) { + this.#quarantineTurn( + turnKey, + sessionKey, + fenceText, + "turn identity mismatch", + input.admittedAtMs, + ); + throw new CommitThenThrow(new HostLedgerConflictError()); + } + return { admitted: false, turnKey }; + } + const fenceCiphertext = this.#encrypt( + encoder.encode(fenceText), + "turns", + turnKey, + fenceText, + input.admittedAtMs, + ); + const authorityCiphertext = this.#encrypt( + encoder.encode( + canonical({ + authorityHash: input.authorityHash, + recoveryDescriptor: input.recoveryDescriptor, + }), + ), + "turns", + turnKey, + fenceText, + input.admittedAtMs, + ); + this.#db + .query( + `INSERT INTO turns(session_key,turn_key,run_key,phase,fence_digest,fence_ciphertext,fence_key_id,authority_ciphertext,authority_key_id,admitted_at,byte_count) VALUES(?,?,?,'admitted',?,?,?,?,?,?,?)`, + ) + .run( + sessionKey, + turnKey, + runKey, + this.#fenceBinding(fenceText), + fenceCiphertext, + keyId(fenceCiphertext), + authorityCiphertext, + keyId(authorityCiphertext), + input.admittedAtMs, + descriptor.byteLength, + ); + return { admitted: true, turnKey }; + }, + ); + } finally { + descriptor.fill(0); + authority.fill(0); + } + } + + markTurnRunning(fence: TurnFence, atMs: number): void { + this.#turnTransition(fence, "admitted", "running", atMs, "ordinary"); + } + settleTurn( + fence: TurnFence, + phase: "terminal" | "indeterminate", + atMs: number, + ): void { + this.#turnTransition(fence, "running", phase, atMs, "emergency"); + } + + prepareOperation(input: OperationInput): { + prepared: boolean; + operationKey: string; + phase: string; + } { + return this.#prepareReceipt( + "operations", + input, + "operation", + input.operationId, + ); + } + markExecuting(input: OperationInput): void { + this.#operationTransition(input, "prepared", "executing", "ordinary"); + } + settleOperation(input: OperationInput, reconciliationRef: Plain): void { + this.#operationTerminal(input, "settled", reconciliationRef); + } + markOperationIndeterminate( + input: OperationInput, + reconciliationRef: Plain, + ): void { + this.#operationTerminal(input, "indeterminate", reconciliationRef); + } + + recordControl(input: ControlInput): { + recorded: boolean; + receiptKey: string; + phase: string; + } { + validDigest(input.identityDigest); + validTime(input.atMs); + const fenceText = exactFence(input.fence), + sessionKey = this.#id("session", input.fence.sessionId), + turnKey = this.#id("turn", input.fence.turnId), + receiptKey = this.#id("receipt", input.receiptId); + const ref = + input.reconcileRef === undefined + ? undefined + : encoder.encode(canonical(input.reconcileRef)); + try { + return this.#write( + turnKey, + input.kind === "cancel" ? "emergency" : "ordinary", + { + encryptedPlaintextBytes: ref?.byteLength ?? 0, + rowsInserted: 1, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 2, + }, + () => { + this.#requireTurn(turnKey, sessionKey, fenceText); + this.#assertNotDeleted(sessionKey); + const row = this.#db + .query< + { + identity_digest: string; + kind: string; + phase: string; + descriptor_ciphertext: string | null; + }, + [string] + >( + "SELECT identity_digest,kind,phase,descriptor_ciphertext FROM control_receipts WHERE receipt_key=?", + ) + .get(receiptKey); + if (row) { + const storedRef = row.descriptor_ciphertext + ? this.#decrypt( + row.descriptor_ciphertext, + "control_receipts", + receiptKey, + fenceText, + ) + : undefined; + const refMatches = + storedRef === undefined + ? ref === undefined + : ref !== undefined && + Buffer.from(storedRef).equals(Buffer.from(ref)); + storedRef?.fill(0); + if ( + row.identity_digest !== input.identityDigest || + row.kind !== input.kind || + !refMatches + ) { + this.#quarantineTurn( + turnKey, + sessionKey, + fenceText, + "control identity mismatch", + input.atMs, + ); + throw new CommitThenThrow(new HostLedgerConflictError()); + } + return { recorded: false, receiptKey, phase: row.phase }; + } + const cipher = ref + ? this.#encrypt( + ref, + "control_receipts", + receiptKey, + fenceText, + input.atMs, + ) + : null; + this.#db + .query( + `INSERT INTO control_receipts(receipt_key,session_key,turn_key,kind,phase,identity_digest,descriptor_ciphertext,descriptor_key_id,created_at,byte_count) VALUES(?,?,?,?,'prepared',?,?,?,?,?)`, + ) + .run( + receiptKey, + sessionKey, + turnKey, + input.kind, + input.identityDigest, + cipher, + cipher ? keyId(cipher) : null, + input.atMs, + ref?.byteLength ?? 0, + ); + return { recorded: true, receiptKey, phase: "prepared" }; + }, + ); + } finally { + ref?.fill(0); + } + } + settleControl(input: ControlInput, reconciliationRef?: Plain): void { + this.#controlTerminal(input, "settled", reconciliationRef); + } + indeterminateControl(input: ControlInput, reconciliationRef?: Plain): void { + this.#controlTerminal(input, "indeterminate", reconciliationRef); + } + + enqueueOutbox(input: OutboxInput): { enqueued: boolean; outboxKey: string } { + validTime(input.atMs); + validDigest(input.destinationDigest); + const fenceText = exactFence(input.fence), + sessionKey = this.#id("session", input.fence.sessionId), + turnKey = this.#id("turn", input.fence.turnId), + outboxKey = this.#id("receipt", input.outboxId), + bodyDigest = this.#id( + "receipt", + `outbox-body-v1:${createHash("sha256").update(input.temporaryBody).digest("hex")}`, + ); + return this.#write( + turnKey, + "ordinary", + { + encryptedPlaintextBytes: input.temporaryBody.byteLength, + rowsInserted: 1, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 2, + }, + () => { + this.#requireTurn(turnKey, sessionKey, fenceText); + this.#assertNotDeleted(sessionKey); + const row = this.#db + .query< + { + destination_digest: string; + body_digest: string; + body_ciphertext: string | null; + phase: string; + }, + [string] + >( + "SELECT destination_digest,body_digest,body_ciphertext,phase FROM outbox WHERE outbox_key=?", + ) + .get(outboxKey); + if (row) { + const storedBody = row.body_ciphertext + ? this.#decrypt(row.body_ciphertext, "outbox", outboxKey, fenceText) + : undefined; + const bodyMatches = + (storedBody !== undefined && + Buffer.from(storedBody).equals( + Buffer.from(input.temporaryBody), + )) || + (storedBody === undefined && + row.body_digest === bodyDigest && + (row.phase === "acked" || row.phase === "failed")); + storedBody?.fill(0); + if ( + row.destination_digest !== input.destinationDigest || + row.body_digest !== bodyDigest || + !bodyMatches + ) { + this.#quarantineTurn( + turnKey, + sessionKey, + fenceText, + "outbox identity mismatch", + input.atMs, + ); + throw new CommitThenThrow(new HostLedgerConflictError()); + } + return { enqueued: false, outboxKey }; + } + const body = this.#encrypt( + input.temporaryBody, + "outbox", + outboxKey, + fenceText, + input.atMs, + ); + this.#db + .query( + `INSERT INTO outbox(outbox_key,session_key,turn_key,phase,destination_digest,body_digest,body_ciphertext,body_key_id,created_at,byte_count) VALUES(?,?,?,'queued',?,?,?,?,?,?)`, + ) + .run( + outboxKey, + sessionKey, + turnKey, + input.destinationDigest, + bodyDigest, + body, + keyId(body), + input.atMs, + input.temporaryBody.byteLength, + ); + return { enqueued: true, outboxKey }; + }, + ); + } + claimOutbox( + limit = 32, + ): Array<{ outboxKey: string; temporaryBody: Uint8Array }> { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 128) + throw new Error("invalid outbox claim limit"); + const rows = this.#db + .query< + { + outbox_key: string; + turn_key: string; + body_ciphertext: string; + fence_ciphertext: string; + }, + [number] + >( + `SELECT o.outbox_key,o.turn_key,o.body_ciphertext,t.fence_ciphertext FROM outbox o JOIN turns t ON t.turn_key=o.turn_key WHERE o.phase='queued' ORDER BY o.created_at LIMIT ?`, + ) + .all(limit); + const result: Array<{ outboxKey: string; temporaryBody: Uint8Array }> = []; + for (const row of rows) + this.#write( + row.turn_key, + "ordinary", + { + encryptedPlaintextBytes: 0, + rowsInserted: 0, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 1, + }, + () => { + const changed = this.#db + .query( + "UPDATE outbox SET phase='claimed',claimed_at=?,attempts=attempts+1 WHERE outbox_key=? AND phase='queued'", + ) + .run(this.#now(), row.outbox_key); + if (changed.changes === 1) { + const fence = decoder.decode( + this.#decrypt( + row.fence_ciphertext, + "turns", + row.turn_key, + this.#fenceFor(row.turn_key), + ), + ); + result.push({ + outboxKey: row.outbox_key, + temporaryBody: this.#decrypt( + row.body_ciphertext, + "outbox", + row.outbox_key, + fence, + ), + }); + } + }, + ); + return result; + } + ackOutbox(outboxKey: string, atMs: number): void { + validTime(atMs); + this.#outboxTerminal(outboxKey, "acked", atMs); + } + failOutbox(outboxKey: string, atMs: number): void { + validTime(atMs); + this.#outboxTerminal(outboxKey, "failed", atMs); + } + + scanRecover( + proofs: ReadonlyMap = new Map(), + ): RecoveryRecord[] { + const records: RecoveryRecord[] = []; + const operations = this.#db + .query< + { + operation_key: string; + turn_key: string; + phase: string; + identity_digest: string; + }, + [] + >( + "SELECT operation_key,turn_key,phase,identity_digest FROM operations WHERE phase IN ('prepared','executing')", + ) + .all(); + for (const row of operations) { + let recoveredPhase = row.phase; + if (row.phase === "executing") { + const proof = proofs.get(row.operation_key); + if ( + proof?.type === "positive_gateway_receipt_v1" && + proof.operationKey === row.operation_key && + proof.identityDigest === row.identity_digest && + DIGEST.test(proof.identityDigest) && + DIGEST.test(proof.receiptDigest) && + this.#verifyPositiveProof?.(proof) === true + ) { + const fence = this.#fenceFor(row.turn_key); + const bytes = encoder.encode(canonical(proof.reconciliationRef)); + try { + const cipher = this.#encrypt( + bytes, + "operations", + row.operation_key, + fence, + this.#now(), + ); + this.#write( + row.turn_key, + "emergency", + { + encryptedPlaintextBytes: bytes.byteLength, + rowsInserted: 0, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 1, + }, + () => + this.#db + .query( + "UPDATE operations SET phase='settled',reconcile_ciphertext=?,reconcile_key_id=?,terminal_at=? WHERE operation_key=? AND phase='executing'", + ) + .run(cipher, keyId(cipher), this.#now(), row.operation_key), + ); + } finally { + bytes.fill(0); + } + recoveredPhase = "settled"; + } else { + this.#write( + row.turn_key, + "emergency", + { + encryptedPlaintextBytes: 0, + rowsInserted: 0, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 1, + }, + () => + this.#db + .query( + "UPDATE operations SET phase='indeterminate',terminal_at=? WHERE operation_key=? AND phase='executing'", + ) + .run(this.#now(), row.operation_key), + ); + recoveredPhase = "indeterminate"; + } + } + records.push({ + kind: "operation", + opaqueKey: row.operation_key, + phase: recoveredPhase, + replayable: recoveredPhase === "prepared", + }); + } + for (const row of this.#db + .query<{ turn_key: string; phase: string }, []>( + "SELECT turn_key,phase FROM turns WHERE phase IN ('admitted','running')", + ) + .all()) + records.push({ + kind: "turn", + opaqueKey: row.turn_key, + phase: row.phase, + replayable: false, + }); + for (const row of this.#db + .query<{ receipt_key: string; phase: string }, []>( + "SELECT receipt_key,phase FROM control_receipts WHERE phase='prepared'", + ) + .all()) + records.push({ + kind: "control", + opaqueKey: row.receipt_key, + phase: row.phase, + replayable: false, + }); + for (const row of this.#db + .query<{ outbox_key: string; turn_key: string; phase: string }, []>( + "SELECT outbox_key,turn_key,phase FROM outbox WHERE phase IN ('queued','claimed')", + ) + .all()) { + if (row.phase === "claimed") + this.#write( + row.turn_key, + "emergency", + { + encryptedPlaintextBytes: 0, + rowsInserted: 0, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 1, + }, + () => + this.#db + .query( + "UPDATE outbox SET phase='queued',claimed_at=NULL WHERE outbox_key=? AND phase='claimed'", + ) + .run(row.outbox_key), + ); + records.push({ + kind: "outbox", + opaqueKey: row.outbox_key, + phase: "queued", + replayable: true, + }); + } + return records; + } + + quarantine( + fence: TurnFence, + evidenceId: string, + evidence: Plain, + atMs: number, + ): void { + validTime(atMs); + const fenceText = exactFence(fence), + sessionKey = this.#id("session", fence.sessionId), + turnKey = this.#id("turn", fence.turnId), + evidenceKey = this.#id("receipt", evidenceId); + const bytes = encoder.encode(canonical(evidence)); + try { + this.#write( + turnKey, + "emergency", + { + encryptedPlaintextBytes: bytes.byteLength, + rowsInserted: 1, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 2, + }, + () => { + this.#requireTurn(turnKey, sessionKey, fenceText); + const cipher = this.#encrypt( + bytes, + "quarantine_evidence", + evidenceKey, + fenceText, + atMs, + ); + this.#db + .query( + "UPDATE turns SET phase='quarantined',terminal_at=? WHERE turn_key=?", + ) + .run(atMs, turnKey); + this.#db + .query( + `INSERT OR IGNORE INTO quarantine_evidence(evidence_key,session_key,turn_key,phase,evidence_ciphertext,evidence_key_id,created_at,terminal_at,byte_count) VALUES(?,?,?,'quarantined',?,?,?,?,?)`, + ) + .run( + evidenceKey, + sessionKey, + turnKey, + cipher, + keyId(cipher), + atMs, + atMs, + bytes.byteLength, + ); + }, + ); + } finally { + bytes.fill(0); + } + } + purgeExpired(nowMs: number): number { + validTime(nowMs); + const cutoff = nowMs - AGENT_HOST_LEDGER_RETENTION_MS; + const rowsDeleted = this.#db + .query<{ count: number }, [number, number, number, number, number]>( + `SELECT + (SELECT COUNT(*) FROM quarantine_evidence WHERE terminal_at < ?) + + (SELECT COUNT(*) FROM operations WHERE turn_key IN (SELECT turn_key FROM turns WHERE phase IN ('terminal','indeterminate','quarantined') AND terminal_at < ?)) + + (SELECT COUNT(*) FROM control_receipts WHERE turn_key IN (SELECT turn_key FROM turns WHERE phase IN ('terminal','indeterminate','quarantined') AND terminal_at < ?)) + + (SELECT COUNT(*) FROM outbox WHERE turn_key IN (SELECT turn_key FROM turns WHERE phase IN ('terminal','indeterminate','quarantined') AND terminal_at < ?)) + + (SELECT COUNT(*) FROM turns WHERE phase IN ('terminal','indeterminate','quarantined') AND terminal_at < ?) + + (SELECT COUNT(*) FROM deletion_tombstones WHERE expires_at < ${nowMs}) AS count`, + ) + .get(cutoff, cutoff, cutoff, cutoff, cutoff)!.count; + return this.#write( + undefined, + "emergency", + { + encryptedPlaintextBytes: 0, + rowsInserted: 0, + rowsUpdated: 0, + rowsDeleted, + affectedIndexes: 4, + checkpointPossible: true, + }, + () => { + const a = this.#db + .query("DELETE FROM quarantine_evidence WHERE terminal_at < ?") + .run(cutoff).changes; + const b = this.#db + .query( + "DELETE FROM turns WHERE phase IN ('terminal','indeterminate','quarantined') AND terminal_at < ?", + ) + .run(cutoff).changes; + const c = this.#db + .query("DELETE FROM deletion_tombstones WHERE expires_at < ?") + .run(nowMs).changes; + return a + b + c; + }, + ); + } + deleteSession(sessionId: string, atMs: number): number { + validTime(atMs); + const sessionKey = this.#id("session", sessionId); + const rowsDeleted = this.#db + .query<{ count: number }, [string, string, string, string, string]>( + `SELECT + (SELECT COUNT(*) FROM operations WHERE session_key=?) + + (SELECT COUNT(*) FROM control_receipts WHERE session_key=?) + + (SELECT COUNT(*) FROM outbox WHERE session_key=?) + + (SELECT COUNT(*) FROM quarantine_evidence WHERE session_key=?) + + (SELECT COUNT(*) FROM turns WHERE session_key=?) AS count`, + ) + .get(sessionKey, sessionKey, sessionKey, sessionKey, sessionKey)!.count; + return this.#write( + undefined, + "emergency", + { + encryptedPlaintextBytes: 0, + rowsInserted: 1, + rowsUpdated: 0, + rowsDeleted, + affectedIndexes: 8, + checkpointPossible: true, + }, + () => { + const count = this.#db + .query("DELETE FROM turns WHERE session_key=?") + .run(sessionKey).changes; + this.#db + .query("DELETE FROM quarantine_evidence WHERE session_key=?") + .run(sessionKey); + this.#db + .query( + "INSERT INTO deletion_tombstones(session_key,deleted_at,expires_at) VALUES(?,?,?) ON CONFLICT(session_key) DO UPDATE SET deleted_at=excluded.deleted_at,expires_at=excluded.expires_at", + ) + .run(sessionKey, atMs, atMs + AGENT_HOST_LEDGER_RETENTION_MS); + return count; + }, + ); + } + validateEncryptedRows(): void { + for (const row of this.#db + .query< + { + turn_key: string; + fence_ciphertext: string; + authority_ciphertext: string; + }, + [] + >("SELECT turn_key,fence_ciphertext,authority_ciphertext FROM turns") + .all()) { + const fence = this.#fenceFor(row.turn_key); + const a = this.#decrypt( + row.authority_ciphertext, + "turns", + row.turn_key, + fence, + ); + a.fill(0); + } + } + close(): void { + if (this.#closed) return; + this.#db + .query( + "DELETE FROM writer WHERE singleton=1 AND claim_nonce=? AND process_id=?", + ) + .run(this.#writerNonce, process.pid); + this.#db.close(); + this.#reserve.close(); + this.#closed = true; + } + + #turnTransition( + fence: TurnFence, + from: "admitted" | "running", + to: "running" | "terminal" | "indeterminate", + atMs: number, + writeClass: LedgerWriteClass, + ): void { + validTime(atMs); + const fenceText = exactFence(fence); + const sessionKey = this.#id("session", fence.sessionId); + const turnKey = this.#id("turn", fence.turnId); + this.#write( + turnKey, + writeClass, + { + encryptedPlaintextBytes: 0, + rowsInserted: 0, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 1, + }, + () => { + this.#requireTurn(turnKey, sessionKey, fenceText); + const changed = this.#db + .query( + "UPDATE turns SET phase=?,terminal_at=? WHERE turn_key=? AND phase=?", + ) + .run(to, to === "running" ? null : atMs, turnKey, from); + if (changed.changes !== 1) + throw new HostLedgerConflictError("illegal turn transition"); + }, + ); + } + + #prepareReceipt( + _table: "operations", + input: OperationInput, + idKind: "operation", + rawId: string, + ) { + validDigest(input.identityDigest); + validTime(input.atMs); + const fenceText = exactFence(input.fence), + sessionKey = this.#id("session", input.fence.sessionId), + turnKey = this.#id("turn", input.fence.turnId), + operationKey = this.#id(idKind, rawId); + const bytes = encoder.encode(canonical(input.reconcileRef)); + try { + return this.#write( + turnKey, + "ordinary", + { + encryptedPlaintextBytes: bytes.byteLength, + rowsInserted: 1, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 2, + }, + () => { + this.#requireTurn(turnKey, sessionKey, fenceText); + this.#assertNotDeleted(sessionKey); + const row = this.#db + .query< + { + identity_digest: string; + phase: string; + descriptor_ciphertext: string; + }, + [string] + >( + "SELECT identity_digest,phase,descriptor_ciphertext FROM operations WHERE operation_key=?", + ) + .get(operationKey); + if (row) { + const storedDescriptor = this.#decrypt( + row.descriptor_ciphertext, + "operations", + operationKey, + fenceText, + ); + const descriptorMatches = Buffer.from(storedDescriptor).equals( + Buffer.from(bytes), + ); + storedDescriptor.fill(0); + if ( + row.identity_digest !== input.identityDigest || + !descriptorMatches + ) { + this.#quarantineTurn( + turnKey, + sessionKey, + fenceText, + "operation identity mismatch", + input.atMs, + ); + throw new CommitThenThrow(new HostLedgerConflictError()); + } + return { prepared: false, operationKey, phase: row.phase }; + } + const descriptor = this.#encrypt( + bytes, + "operations", + operationKey, + fenceText, + input.atMs, + ); + this.#db + .query( + `INSERT INTO operations(operation_key,session_key,turn_key,phase,identity_digest,descriptor_ciphertext,descriptor_key_id,prepared_at,byte_count) VALUES(?,?,?,'prepared',?,?,?,?,?)`, + ) + .run( + operationKey, + sessionKey, + turnKey, + input.identityDigest, + descriptor, + keyId(descriptor), + input.atMs, + bytes.byteLength, + ); + return { prepared: true, operationKey, phase: "prepared" }; + }, + ); + } finally { + bytes.fill(0); + } + } + #operationTransition( + input: OperationInput, + from: string, + to: string, + writeClass: LedgerWriteClass, + ) { + validTime(input.atMs); + const fenceText = exactFence(input.fence), + sessionKey = this.#id("session", input.fence.sessionId), + turnKey = this.#id("turn", input.fence.turnId), + operationKey = this.#id("operation", input.operationId); + this.#write( + turnKey, + writeClass, + { + encryptedPlaintextBytes: 0, + rowsInserted: 0, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 1, + }, + () => { + this.#requireTurn(turnKey, sessionKey, fenceText); + const row = this.#db + .query< + { identity_digest: string; phase: string; turn_key: string }, + [string] + >( + "SELECT identity_digest,phase,turn_key FROM operations WHERE operation_key=?", + ) + .get(operationKey); + if ( + !row || + row.identity_digest !== input.identityDigest || + row.turn_key !== turnKey || + row.phase !== from + ) + throw new HostLedgerConflictError( + `illegal operation transition to ${to}`, + ); + this.#db + .query( + `UPDATE operations SET phase=?,executing_at=? WHERE operation_key=?`, + ) + .run(to, input.atMs, operationKey); + }, + ); + } + #operationTerminal( + input: OperationInput, + phase: "settled" | "indeterminate", + ref: Plain, + ) { + validTime(input.atMs); + const fenceText = exactFence(input.fence), + sessionKey = this.#id("session", input.fence.sessionId), + turnKey = this.#id("turn", input.fence.turnId), + operationKey = this.#id("operation", input.operationId), + bytes = encoder.encode(canonical(ref)); + try { + this.#write( + turnKey, + "emergency", + { + encryptedPlaintextBytes: bytes.byteLength, + rowsInserted: 0, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 1, + }, + () => { + this.#requireTurn(turnKey, sessionKey, fenceText); + const cipher = this.#encrypt( + bytes, + "operations", + operationKey, + fenceText, + input.atMs, + ); + const changed = this.#db + .query( + "UPDATE operations SET phase=?,reconcile_ciphertext=?,reconcile_key_id=?,terminal_at=? WHERE operation_key=? AND turn_key=? AND identity_digest=? AND phase='executing'", + ) + .run( + phase, + cipher, + keyId(cipher), + input.atMs, + operationKey, + turnKey, + input.identityDigest, + ); + if (changed.changes !== 1) + throw new HostLedgerConflictError( + "illegal operation terminal transition", + ); + }, + ); + } finally { + bytes.fill(0); + } + } + #controlTerminal( + input: ControlInput, + phase: "settled" | "indeterminate", + ref?: Plain, + ) { + validTime(input.atMs); + const receiptKey = this.#id("receipt", input.receiptId), + sessionKey = this.#id("session", input.fence.sessionId), + turnKey = this.#id("turn", input.fence.turnId), + fenceText = exactFence(input.fence), + bytes = ref === undefined ? undefined : encoder.encode(canonical(ref)); + try { + this.#write( + turnKey, + "emergency", + { + encryptedPlaintextBytes: bytes?.byteLength ?? 0, + rowsInserted: 0, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 1, + }, + () => { + this.#requireTurn(turnKey, sessionKey, fenceText); + const cipher = bytes + ? this.#encrypt( + bytes, + "control_receipts", + receiptKey, + fenceText, + input.atMs, + ) + : null; + const changed = this.#db + .query( + "UPDATE control_receipts SET phase=?,reconcile_ciphertext=?,reconcile_key_id=?,terminal_at=? WHERE receipt_key=? AND turn_key=? AND identity_digest=? AND kind=? AND phase='prepared'", + ) + .run( + phase, + cipher, + cipher ? keyId(cipher) : null, + input.atMs, + receiptKey, + turnKey, + input.identityDigest, + input.kind, + ); + if (changed.changes !== 1) + throw new HostLedgerConflictError("illegal control transition"); + }, + ); + } finally { + bytes?.fill(0); + } + } + #outboxTerminal(outboxKey: string, phase: "acked" | "failed", atMs: number) { + if (!DIGEST.test(outboxKey)) throw new Error("outbox key must be opaque"); + const row = this.#db + .query<{ turn_key: string }, [string]>( + "SELECT turn_key FROM outbox WHERE outbox_key=?", + ) + .get(outboxKey); + if (!row) throw new HostLedgerConflictError("unknown outbox row"); + this.#write( + row.turn_key, + "emergency", + { + encryptedPlaintextBytes: 0, + rowsInserted: 0, + rowsUpdated: 1, + rowsDeleted: 0, + affectedIndexes: 1, + }, + () => { + const changed = this.#db + .query( + "UPDATE outbox SET phase=?,body_ciphertext=NULL,body_key_id=NULL,byte_count=0,terminal_at=? WHERE outbox_key=? AND phase='claimed'", + ) + .run(phase, atMs, outboxKey); + if (changed.changes !== 1) + throw new HostLedgerConflictError( + "illegal outbox terminal transition", + ); + }, + ); + } + #write( + turnKey: string | undefined, + writeClass: LedgerWriteClass, + shape: WriteShape, + fn: () => T, + ): T { + this.#open(); + const before = this.#physical.snapshot(this.#path); + const accounting = this.#db + .query<{ global_charge: number }, []>( + "SELECT global_charge FROM accounting WHERE singleton=1", + ) + .get()!; + const turn = turnKey + ? this.#db + .query<{ charged_bytes: number }, [string]>( + "SELECT charged_bytes FROM turns WHERE turn_key=?", + ) + .get(turnKey) + : undefined; + const emergency = writeClass === "emergency"; + const effectiveShape = emergency + ? { ...shape, checkpointPossible: true } + : shape; + const reserve = this.#reserve.snapshot(); + const liability = preflightLiability({ + shape: effectiveShape, + writeClass, + currentPhysicalBytes: before.totalBytes, + globalChargedBytes: accounting.global_charge, + turnChargedBytes: turn?.charged_bytes ?? 0, + activeLiabilityBytes: this.#activeLiability, + availableBytes: before.availableBytes, + reserveAvailableBytes: Math.min( + reserve.logicalBytes, + reserve.allocatedBytes, + ), + chargeTurn: turnKey !== undefined, + }); + this.#activeLiability += liability.bytes; + let reserveConsumed = false; + let committed = false; + try { + if (emergency) { + this.#injectFault("reserve:before-consume"); + this.#reserve.consume(liability.bytes); + reserveConsumed = true; + this.#injectFault("reserve:after-consume"); + } + this.#injectFault("transaction:before-begin"); + this.#db.exec("BEGIN IMMEDIATE"); + try { + this.#injectFault("transaction:after-begin"); + const result = fn(); + this.#applyCharge( + turnKey, + liability.bytes, + liability.turnCharge, + before.totalBytes + liability.bytes, + ); + this.#injectFault("transaction:before-commit"); + this.#db.exec("COMMIT"); + committed = true; + this.#injectFault("transaction:after-commit"); + const after = this.#physical.snapshot(this.#path); + assertCommittedBound(before, after, liability); + if (emergency) { + this.#checkpointAndRestoreReserve(true); + reserveConsumed = false; + } else if (shape.checkpointPossible) { + this.#checkpointAndRestoreReserve(true); + } + return result; + } catch (error) { + if (error instanceof CommitThenThrow && !committed) { + this.#applyCharge( + turnKey, + liability.bytes, + liability.turnCharge, + before.totalBytes + liability.bytes, + ); + this.#injectFault("transaction:before-commit"); + this.#db.exec("COMMIT"); + committed = true; + this.#injectFault("transaction:after-commit"); + const after = this.#physical.snapshot(this.#path); + assertCommittedBound(before, after, liability); + if (emergency) { + this.#checkpointAndRestoreReserve(true); + reserveConsumed = false; + } + throw error.rejection; + } + if (!committed) { + try { + this.#db.exec("ROLLBACK"); + } catch {} + } + throw error; + } + } finally { + if (reserveConsumed) { + try { + if (committed) this.#checkpointAndRestoreReserve(false); + else this.#reserve.replenish(); + } catch { + // Preserve the operation failure. Reopen reconciliation checkpoints + // before its single reserve allocation pass. + } + } + this.#activeLiability -= liability.bytes; + } + } + #applyCharge( + turnKey: string | undefined, + bytes: number, + turnCharge: number, + physicalUpperBound: number, + ) { + this.#db + .query( + "UPDATE accounting SET global_charge=global_charge+?,physical_high_water=MAX(physical_high_water,?),active_liability=0 WHERE singleton=1", + ) + .run(bytes, physicalUpperBound); + if (turnKey) + this.#db + .query( + "UPDATE turns SET charged_bytes=charged_bytes+? WHERE turn_key=?", + ) + .run(turnCharge, turnKey); + } + #checkpoint(inject: boolean) { + if (inject) this.#injectFault("checkpoint:before"); + const checkpoint = this.#db + .query<{ busy: number; log: number; checkpointed: number }, []>( + "PRAGMA wal_checkpoint(TRUNCATE)", + ) + .get(); + if ( + !checkpoint || + checkpoint.busy !== 0 || + checkpoint.log !== checkpoint.checkpointed + ) + throw new Error("Host ledger checkpoint did not complete"); + if (inject) this.#injectFault("checkpoint:after"); + } + #checkpointAndRestoreReserve(inject: boolean) { + this.#checkpoint(inject); + const physical = this.#physical.snapshot(this.#path); + if (inject) this.#injectFault("reserve:before-recreate"); + this.#db.exec("BEGIN IMMEDIATE"); + let committed = false; + try { + this.#db + .query( + "UPDATE accounting SET global_charge=?,physical_high_water=MAX(physical_high_water,?),active_liability=0 WHERE singleton=1", + ) + .run(physical.totalBytes, physical.totalBytes); + this.#db.exec("COMMIT"); + committed = true; + } finally { + if (!committed) { + try { + this.#db.exec("ROLLBACK"); + } catch {} + } + } + // The accounting update itself wrote a WAL frame. Retire it before taking + // the released filesystem blocks back for the generation. + this.#checkpoint(false); + this.#reserve.replenish(); + if (inject) this.#injectFault("reserve:after-recreate"); + } + #encrypt( + bytes: Uint8Array, + table: string, + pk: string, + fence: string, + now: number, + ) { + return this.#keys.encrypt( + bytes, + { + table, + opaquePrimaryKey: pk, + exactFence: DIGEST.test(fence) ? fence : this.#fenceBinding(fence), + }, + now, + ); + } + #decrypt(value: string, table: string, pk: string, fence: string) { + return this.#keys.decrypt( + value, + { + table, + opaquePrimaryKey: pk, + exactFence: DIGEST.test(fence) ? fence : this.#fenceBinding(fence), + }, + this.#now(), + ); + } + #id(kind: "session" | "run" | "turn" | "operation" | "receipt", raw: string) { + return this.#keys.opaqueId(kind, raw); + } + #fenceBinding(fence: string) { + return this.#keys.opaqueId("receipt", `exact-fence-v1:${fence}`); + } + #fenceFor(turnKey: string): string { + const row = this.#db + .query<{ fence_digest: string }, [string]>( + "SELECT fence_digest FROM turns WHERE turn_key=?", + ) + .get(turnKey); + if (!row) throw new HostLedgerConflictError("unknown turn"); + return row.fence_digest; + } + #requireTurn(turnKey: string, sessionKey: string, fence: string) { + const row = this.#db + .query<{ session_key: string; fence_ciphertext: string }, [string]>( + "SELECT session_key,fence_ciphertext FROM turns WHERE turn_key=?", + ) + .get(turnKey); + if (!row) throw new HostLedgerConflictError("turn not admitted"); + if (row.session_key !== sessionKey) { + this.#quarantineTurn( + turnKey, + row.session_key, + this.#fenceFor(turnKey), + "session fence mismatch", + this.#now(), + ); + throw new CommitThenThrow( + new HostLedgerConflictError("turn session mismatch"), + ); + } + let clear: Uint8Array; + try { + clear = this.#decrypt(row.fence_ciphertext, "turns", turnKey, fence); + } catch { + this.#quarantineTurn( + turnKey, + row.session_key, + this.#fenceFor(turnKey), + "exact fence authentication mismatch", + this.#now(), + ); + throw new CommitThenThrow( + new HostLedgerConflictError("exact fence mismatch"), + ); + } + try { + if (decoder.decode(clear) !== fence) { + this.#quarantineTurn( + turnKey, + row.session_key, + this.#fenceFor(turnKey), + "exact fence mismatch", + this.#now(), + ); + throw new CommitThenThrow( + new HostLedgerConflictError("exact fence mismatch"), + ); + } + } finally { + clear.fill(0); + } + } + #assertNotDeleted(sessionKey: string) { + if ( + this.#db + .query("SELECT 1 FROM deletion_tombstones WHERE session_key=?") + .get(sessionKey) + ) + throw new HostLedgerDeletedError(); + } + #quarantineTurn( + turnKey: string, + sessionKey: string, + fence: string, + reason: string, + atMs: number, + ) { + const evidenceKey = this.#id("receipt", `${turnKey}:${atMs}:${reason}`), + bytes = encoder.encode(canonical({ reason })); + try { + const cipher = this.#encrypt( + bytes, + "quarantine_evidence", + evidenceKey, + fence, + atMs, + ); + this.#db + .query( + "UPDATE turns SET phase='quarantined',terminal_at=? WHERE turn_key=?", + ) + .run(atMs, turnKey); + this.#db + .query( + `INSERT OR IGNORE INTO quarantine_evidence(evidence_key,session_key,turn_key,phase,evidence_ciphertext,evidence_key_id,created_at,terminal_at,byte_count) VALUES(?,?,?,'quarantined',?,?,?,?,?)`, + ) + .run( + evidenceKey, + sessionKey, + turnKey, + cipher, + keyId(cipher), + atMs, + atMs, + bytes.byteLength, + ); + } finally { + bytes.fill(0); + } + } + #claimWriter() { + this.#db.exec("BEGIN IMMEDIATE"); + try { + const existing = this.#db + .query<{ process_id: number }, []>( + "SELECT process_id FROM writer WHERE singleton=1", + ) + .get(); + if (existing && processIsAlive(existing.process_id)) + throw new Error("Host ledger already has a live sole writer"); + this.#db + .query( + "INSERT INTO writer(singleton,claim_nonce,process_id,claimed_at) VALUES(1,?,?,?) ON CONFLICT(singleton) DO UPDATE SET claim_nonce=excluded.claim_nonce,process_id=excluded.process_id,claimed_at=excluded.claimed_at", + ) + .run(this.#writerNonce, process.pid, this.#now()); + this.#db.exec("COMMIT"); + } catch (error) { + try { + this.#db.exec("ROLLBACK"); + } catch {} + throw error; + } + } + #updatePhysicalHighWater() { + const size = this.#physical.snapshot(this.#path).totalBytes; + this.#db + .query("UPDATE accounting SET physical_high_water=? WHERE singleton=1") + .run(size); + } + #open() { + if (this.#closed) throw new Error("Host ledger is closed"); + } +} +function processIsAlive(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} +function exists(path: string) { + try { + lstatSync(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} +function preparePrivatePath(path: string) { + const root = parse(path).root; + let current = root; + for (const part of dirname(path) + .slice(root.length) + .split("/") + .filter(Boolean)) { + current = resolve(current, part); + try { + const stat = lstatSync(current); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error("unsafe Host ledger path"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + mkdirSync(current, { mode: 0o700 }); + } + } + if (exists(path)) { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) + throw new Error("unsafe Host ledger file"); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + closeSync(fd); + } +} +function preflightSidecars(path: string) { + for (const suffix of ["-wal", "-shm"]) { + const side = `${path}${suffix}`; + if (exists(side)) { + const stat = lstatSync(side); + if (stat.isSymbolicLink() || !stat.isFile()) + throw new Error("unsafe Host ledger sidecar"); + } + } +} +function secureFiles(path: string) { + for (const file of [path, `${path}-wal`, `${path}-shm`]) + if (exists(file)) chmodSync(file, 0o600); +} diff --git a/packages/core/opensession-server/src/agent-host/transport-integration.test.ts b/packages/core/opensession-server/src/agent-host/transport-integration.test.ts new file mode 100644 index 0000000000..64560acbaa --- /dev/null +++ b/packages/core/opensession-server/src/agent-host/transport-integration.test.ts @@ -0,0 +1,814 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AGENT_HOST_SUPERVISION_AUDIENCE, + AGENT_HOST_SUPERVISION_PURPOSE, + AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN, + MAX_AGENT_HOST_REPLAY_BYTES, + MAX_AGENT_HOST_REPLAY_FRAMES, + hashAgentOperationDescriptorV1, + hashAgentTurnSpecV2, + serializeAgentHostSupervisionAuthorityV2, + type AgentHostChallengeDescriptorV4, + type AgentHostSupervisionPublicKeyringV2, + type AgentOperationReceiptV1, + type AgentTurnFence, + type AgentHostTurnTerminalV5, + type AgentTurnSpec, +} from "@tellahq/opensession-protocol"; +import { AgentHostClient } from "../server/agent-host-client"; +import { createAgentHostSupervisionSigner } from "../server/session-kernel/agent-host-supervision-signer"; +import type { + AgentHostOperationQuery, + AgentHostOperationTransport, + AgentTurnDriver, + AgentTurnResult, +} from "./driver"; +import { createAgentHost, type AgentHost } from "./host"; + +const digest = (character: string) => `sha256:${character.repeat(64)}` as const; +const fence: AgentTurnFence = { + sessionId: "session-transport-1", + runId: "run-transport-1", + turnId: "turn-transport-1", + generation: 1, +}; +const descriptor = { + version: 1 as const, + kind: "model" as const, + stepId: "step-transport-1", + transcript: { + throughChangeSeq: 4, + entryIds: ["entry-transport-1"], + digest: digest("a"), + }, + modelPolicyHash: digest("b"), + adapterRequestVersion: "model.v1", +}; +const descriptorDigest = await hashAgentOperationDescriptorV1(descriptor); +const secondDescriptor = { + ...descriptor, + stepId: "step-transport-2", + transcript: { + throughChangeSeq: 4, + entryIds: ["entry-transport-2"], + digest: digest("f"), + }, +}; +const secondDescriptorDigest = + await hashAgentOperationDescriptorV1(secondDescriptor); +const startedAt = Date.now(); +const spec: AgentTurnSpec = { + fence, + initialOperation: { + operationId: "operation-transport-1", + descriptor, + descriptorDigest, + deadlineMs: startedAt + 120_000, + }, + transcript: { afterChangeSeq: 4, maxAppendBytes: 4096, requireAck: true }, + limits: { + turnDeadlineMs: startedAt + 180_000, + maxInFlightOperations: 2, + maxBufferedStreamBytes: 512 * 1024, + maxBufferedStreamChunks: 32, + }, +}; +const planHash = await hashAgentTurnSpecV2(spec); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +async function eventually(check: () => boolean, message: string) { + const deadline = Date.now() + 2_000; + while (!check()) { + if (Date.now() >= deadline) throw new Error(message); + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} + +class FakeDriver implements AgentTurnDriver { + transport?: AgentHostOperationTransport; + readonly delivered: { seq: number; bytes: string }[] = []; + readonly deliveredOperationIds: string[] = []; + readonly deliveryStarted: number[] = []; + readonly deliveryGates = new Map>>(); + cancelCalls = 0; + shutdownCalls = 0; + private readonly result = deferred(); + + constructor(private readonly requestSecondOperation = false) {} + + async run(_spec: AgentTurnSpec, transport: AgentHostOperationTransport) { + this.transport = transport; + await transport.requestOperation(spec.initialOperation); + if (this.requestSecondOperation) + await transport.requestOperation({ + operationId: "operation-transport-2", + descriptor: secondDescriptor, + descriptorDigest: secondDescriptorDigest, + deadlineMs: spec.initialOperation.deadlineMs, + }); + return this.result.promise; + } + async deliverOperationStream(stream: { + operationId: string; + streamSeq: number; + bytes: string; + }) { + this.deliveryStarted.push(stream.streamSeq); + await this.deliveryGates.get(stream.streamSeq)?.promise; + this.delivered.push({ + seq: stream.streamSeq, + bytes: Buffer.from(stream.bytes, "base64url").toString(), + }); + this.deliveredOperationIds.push(stream.operationId); + } + async query(afterStreamSeq: number) { + const query: AgentHostOperationQuery = { + operationId: spec.initialOperation.operationId, + kind: descriptor.kind, + descriptorDigest, + payloadDigest: digest("d"), + afterStreamSeq, + }; + await this.transport!.queryOperation(query); + } + async cancelOperation() { + await this.transport!.cancelOperation({ + operationId: spec.initialOperation.operationId, + cancelId: "cancel-transport-1", + reason: "user", + }); + } + finish(status: AgentTurnResult = { status: "completed" }) { + this.result.resolve(status); + } + async cancel() { + this.cancelCalls++; + } + async shutdown() { + this.shutdownCalls++; + } +} + +function signing() { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const keyId = "transport-supervision-key-1"; + const signer = createAgentHostSupervisionSigner({ + keyId, + privateKeyPkcs8: Uint8Array.from( + privateKey.export({ type: "pkcs8", format: "der" }) as Buffer, + ), + publicKeySpki: Uint8Array.from( + publicKey.export({ type: "spki", format: "der" }) as Buffer, + ), + signingNotBeforeMs: startedAt - 60_000, + signingNotAfterMs: startedAt + 3_600_000, + verifyUntilMs: startedAt + 7_200_000, + status: "active", + }); + const keyring: AgentHostSupervisionPublicKeyringV2 = { + version: 2, + algorithm: AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + domain: AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN, + keys: [ + { + keyId, + status: "active", + publicKeySpki: ( + publicKey.export({ type: "spki", format: "der" }) as Buffer + ).toString("base64url"), + signingNotBeforeMs: startedAt - 60_000, + signingNotAfterMs: startedAt + 3_600_000, + verifyUntilMs: startedAt + 7_200_000, + }, + ], + }; + let epoch = 0; + return { + keyring, + obtainSignedAttach: async (challenge: AgentHostChallengeDescriptorV4) => { + const issuedAtMs = Date.now(); + const expected = { + fence, + planHash, + ...challenge, + supervisorEpoch: ++epoch, + kernelServiceEpoch: `transport-kernel-${epoch}`, + nonce: `transport-${crypto.randomUUID()}`, + audience: AGENT_HOST_SUPERVISION_AUDIENCE, + purpose: AGENT_HOST_SUPERVISION_PURPOSE, + keyId, + issuedAtMs, + expiresAtMs: issuedAtMs + 60_000, + }; + return { + expected, + envelope: signer.sign( + serializeAgentHostSupervisionAuthorityV2({ version: 2, ...expected }), + issuedAtMs, + ), + }; + }, + }; +} + +function operationReceipt( + state: AgentOperationReceiptV1["state"], + operationId = spec.initialOperation.operationId, + operationDescriptorDigest = descriptorDigest, +): AgentOperationReceiptV1 { + const terminal = state === "settled"; + const terminalRef = { + appendId: "append-transport-1", + entryIds: ["entry-output-transport-1"], + firstSeq: 5, + lastSeq: 5, + throughChangeSeq: 5, + requestDigest: digest("9"), + }; + return { + version: 1, + operationId, + kind: descriptor.kind, + fence, + planHash, + authorityHash: digest("c"), + descriptorDigest: operationDescriptorDigest, + payloadDigest: digest("d"), + actorIdentity: { + supervisorEpoch: 1, + hostId: "agent-host-transport-1", + hostGeneration: 1, + hostIncarnation: "transport-incarnation-1", + transcriptAnchor: descriptor.transcript, + }, + state, + acceptedAtMs: startedAt, + ...(state === "prepared" ? {} : { executingAtMs: startedAt + 1 }), + ...(terminal + ? { + completedAtMs: startedAt + 2, + outcome: { status: "succeeded" as const, code: "ok" as const }, + transcriptRefs: [terminalRef], + kernelTerminal: { + outputDigest: digest("e"), + outcomeCode: "ok", + transcriptRefs: [terminalRef], + pendingToolUseEntryIds: [], + }, + } + : {}), + providerRef: { adapterId: "opaque-test", adapterVersion: "1" }, + }; +} + +const resources: { + host: AgentHost; + root: string; + clients: AgentHostClient[]; +}[] = []; +afterEach(async () => { + for (const resource of resources.splice(0)) { + for (const client of resource.clients) client.close(); + await resource.host.stop(); + await rm(resource.root, { recursive: true, force: true }); + } +}); + +type SetupOptions = { + chunks?: string[]; + chunkSource?: AsyncIterable; + settleQueries?: boolean; + twoOperations?: boolean; + dispatchGates?: Map>>; + failpoint?: ConstructorParameters[0]["failpoint"]; + acknowledgeOperationStream?: ConstructorParameters< + typeof AgentHostClient + >[0]["acknowledgeOperationStream"]; + hostFailpoint?: ConstructorParameters[0]["failpoint"]; +}; +async function setup(options: SetupOptions = {}) { + const root = await mkdtemp(join(tmpdir(), "agent-host-transport-")); + const socketPath = join(root, "host.sock"); + const driver = new FakeDriver(options.twoOperations); + const signature = signing(); + const host = createAgentHost({ + socketPath, + createDriver: () => driver, + hostId: "agent-host-transport-1", + hostGeneration: 1, + hostIncarnation: "transport-incarnation-1", + supervisionKeyring: signature.keyring, + reconnectGraceMs: 2_000, + failpoint: options.hostFailpoint, + }); + const dispatchIntents: unknown[] = []; + const queryIntents: any[] = []; + const cancelIntents: unknown[] = []; + const streamAckIntents: unknown[] = []; + const terminalEvents: AgentHostTurnTerminalV5[] = []; + const errors: Error[] = []; + let dispatches = 0; + const clients: AgentHostClient[] = []; + const client = new AgentHostClient({ + socketPath, + obtainSignedAttach: signature.obtainSignedAttach, + dispatchOperation: async (intent) => { + dispatches++; + dispatchIntents.push(intent); + await options.dispatchGates?.get(intent.operationId)?.promise; + return { + receipt: operationReceipt( + "executing", + intent.operationId, + intent.descriptorDigest, + ), + chunks: + options.chunkSource ?? + (options.chunks ?? ["one", "two"]).map((value) => Buffer.from(value)), + }; + }, + queryOperation: async (intent) => { + queryIntents.push(intent); + return { + receipt: operationReceipt( + intent.recovery || options.settleQueries === false + ? "executing" + : "settled", + intent.operationId, + intent.descriptorDigest, + ), + fromStreamSeq: intent.afterStreamSeq + 1, + chunks: intent.recovery + ? (options.chunks ?? ["one", "two"]) + .slice(intent.afterStreamSeq) + .map((value) => Buffer.from(value)) + : [], + }; + }, + cancelOperation: async (intent) => { + cancelIntents.push(intent); + return { + disposition: "indeterminate", + receipt: { + ...operationReceipt( + "executing", + intent.operationId, + intent.descriptorDigest, + ), + state: "indeterminate", + completedAtMs: Date.now(), + kernelTerminal: { + outputDigest: digest("f"), + outcomeCode: "cancellation_ambiguous", + transcriptRefs: [ + { + appendId: "append-transport-1", + entryIds: ["entry-output-transport-1"], + firstSeq: 5, + lastSeq: 5, + throughChangeSeq: 5, + requestDigest: digest("9"), + }, + ], + pendingToolUseEntryIds: [], + }, + transcriptRefs: [ + { + appendId: "append-transport-1", + entryIds: ["entry-output-transport-1"], + firstSeq: 5, + lastSeq: 5, + throughChangeSeq: 5, + requestDigest: digest("9"), + }, + ], + errorCode: "cancellation_ambiguous", + } as AgentOperationReceiptV1, + }; + }, + acknowledgeOperationStream: async (intent) => { + streamAckIntents.push(intent); + await options.acknowledgeOperationStream?.(intent); + }, + onTurnTerminal: async (terminal) => { + terminalEvents.push(terminal); + }, + failpoint: options.failpoint, + onError: (error) => errors.push(error), + }); + clients.push(client); + resources.push({ host, root, clients }); + await host.start(); + return { + client, + host, + driver, + dispatchIntents, + queryIntents, + cancelIntents, + streamAckIntents, + terminalEvents, + errors, + clients, + get dispatches() { + return dispatches; + }, + }; +} + +function assertNoForbiddenAuthority(value: unknown) { + const serialized = JSON.stringify(value).toLowerCase(); + for (const forbidden of [ + "prompt", + "providerconfig", + "mcp", + "credential", + "https://", + "authorization", + "cookie", + "process.env", + "executorgrant", + ]) + expect(serialized).not.toContain(forbidden); +} + +describe("Agent Host v5 end-to-end transport", () => { + test("Driver consumption and durable coordinator ACK gate live publication", async () => { + const driverGate = deferred(); + const coordinatorGate = deferred(); + let coordinatorAckStarted = false; + let publicationResolved = false; + const harness = await setup({ + chunks: ["published"], + acknowledgeOperationStream: async () => { + coordinatorAckStarted = true; + await coordinatorGate.promise; + publicationResolved = true; + }, + }); + harness.driver.deliveryGates.set(1, driverGate); + + await harness.client.connect(fence, planHash); + await harness.client.startTurn(spec); + await eventually( + () => harness.driver.deliveryStarted.includes(1), + "stream chunk was not written through to the Host", + ); + expect(harness.driver.delivered).toEqual([]); + expect(coordinatorAckStarted).toBe(false); + expect(publicationResolved).toBe(false); + + driverGate.resolve(); + await eventually( + () => coordinatorAckStarted, + "Host consumption did not produce a coordinator ACK", + ); + expect(publicationResolved).toBe(false); + expect(harness.streamAckIntents).toEqual([ + { + operationId: spec.initialOperation.operationId, + fence, + kind: descriptor.kind, + descriptorDigest, + throughStreamSeq: 1, + }, + ]); + expect(Object.isFrozen(harness.streamAckIntents[0])).toBe(true); + expect(Object.isFrozen((harness.streamAckIntents[0] as any).fence)).toBe( + true, + ); + assertNoForbiddenAuthority(harness.streamAckIntents); + + coordinatorGate.resolve(); + await eventually( + () => publicationResolved, + "durable coordinator ACK did not release publication", + ); + expect(harness.errors).toEqual([]); + }); + + test("coordinator ACK rejection closes uncertain without pulling a fallback chunk", async () => { + const publication = deferred(); + let chunksPulled = 0; + async function* chunks() { + chunksPulled++; + yield Buffer.from("first"); + await publication.promise; + chunksPulled++; + yield Buffer.from("fallback"); + } + const harness = await setup({ + chunkSource: chunks(), + acknowledgeOperationStream: async () => { + publication.reject(new Error("coordinator stream ACK rejected")); + throw new Error("coordinator stream ACK rejected"); + }, + }); + + await harness.client.connect(fence, planHash); + await harness.client.startTurn(spec); + await eventually( + () => + harness.errors.some((error) => + error.message.includes("coordinator stream ACK rejected"), + ), + "coordinator ACK rejection did not close the client", + ); + expect(chunksPulled).toBe(1); + expect(harness.driver.delivered).toEqual([{ seq: 1, bytes: "first" }]); + expect(harness.queryIntents).toEqual([]); + expect( + (harness.client as any).operations.get(spec.initialOperation.operationId) + .uncertain, + ).toBe(true); + }); + + test("fresh attach dispatches once, streams in order under consumption credit, and terminal waits for drain", async () => { + const harness = await setup({ chunks: ["first", "second"] }); + const secondGate = deferred(); + harness.driver.deliveryGates.set(2, secondGate); + + await harness.client.connect(fence, planHash); + await harness.client.startTurn(spec); + await eventually( + () => + harness.driver.deliveryStarted.includes(2) || harness.errors.length > 0, + "second stream chunk was not offered to the Driver", + ); + expect(harness.errors).toEqual([]); + expect(harness.dispatches).toBe(1); + expect(harness.driver.delivered).toEqual([{ seq: 1, bytes: "first" }]); + + await harness.driver.query(2); + harness.driver.finish(); + await Promise.resolve(); + expect(harness.errors).toEqual([]); + + secondGate.resolve(); + await eventually( + () => harness.driver.delivered.length === 2, + "stream did not drain", + ); + await eventually( + () => + harness.errors.some((error) => + error.message.includes("disconnected"), + ) || !(harness.host as any).active, + "terminal receipt did not complete the drained turn", + ); + expect((harness.host as any).active).toBeUndefined(); + expect(harness.terminalEvents).toHaveLength(1); + expect(harness.client.getTurnTerminal()).toBe(harness.terminalEvents[0]); + expect(await harness.client.waitForTurnTerminal()).toBe(harness.terminalEvents[0]); + expect(harness.terminalEvents[0]).toMatchObject({ + result: { status: "completed" }, + hostGeneration: 1, + operations: [{ operationId: spec.initialOperation.operationId, throughStreamSeq: 2 }], + }); + expect(harness.driver.delivered).toEqual([ + { seq: 1, bytes: "first" }, + { seq: 2, bytes: "second" }, + ]); + expect(harness.queryIntents).toHaveLength(1); + expect(harness.queryIntents[0].afterStreamSeq).toBe(2); + assertNoForbiddenAuthority({ + spec, + dispatchIntents: harness.dispatchIntents, + queryIntents: harness.queryIntents, + }); + }); + + test("binds concurrent operation, query, and cancel receipts to their exact Host intents", async () => { + const firstGate = deferred(); + const secondGate = deferred(); + const harness = await setup({ + chunks: [], + settleQueries: false, + twoOperations: true, + dispatchGates: new Map([ + [spec.initialOperation.operationId, firstGate], + ["operation-transport-2", secondGate], + ]), + }); + + await harness.client.connect(fence, planHash); + await harness.client.startTurn(spec); + await eventually( + () => harness.dispatches === 2, + "operations did not overlap", + ); + + secondGate.resolve(); + await eventually( + () => + (harness.host as any).active?.ops.get("operation-transport-2")?.receipt + ?.state === "executing", + "second operation receipt used the wrong Host sequence", + ); + firstGate.resolve(); + await eventually( + () => + (harness.host as any).active?.ops.get(spec.initialOperation.operationId) + ?.receipt?.state === "executing", + "first operation receipt used another operation's Host sequence", + ); + + const transport = harness.driver.transport!; + await Promise.all([ + transport.queryOperation({ + operationId: spec.initialOperation.operationId, + kind: descriptor.kind, + descriptorDigest, + payloadDigest: digest("d"), + afterStreamSeq: 0, + }), + transport.queryOperation({ + operationId: spec.initialOperation.operationId, + kind: descriptor.kind, + descriptorDigest, + payloadDigest: digest("d"), + afterStreamSeq: 0, + }), + transport.cancelOperation({ + operationId: "operation-transport-2", + cancelId: "cancel-transport-2", + reason: "user", + }), + ]); + await eventually( + () => + harness.queryIntents.length === 2 && harness.cancelIntents.length === 1, + "query/cancel intents were not acknowledged", + ); + expect(harness.errors).toEqual([]); + expect(harness.dispatches).toBe(2); + expect( + (harness.host as any).active.ops.get("operation-transport-2").receipt + .state, + ).toBe("indeterminate"); + }); + + test.each([ + "after_host_message", + "after_coordinator_result", + "before_receipt_write", + "after_receipt_write", + "after_stream_chunk", + ] as const)( + "client crash at %s resumes by query without physical relaunch", + async (point) => { + let crashed = false; + let hostMessages = 0; + const harness = await setup({ + chunks: ["only"], + twoOperations: true, + failpoint: (candidate) => { + if (candidate === "after_host_message") hostMessages++; + const armed = + candidate === point && + (candidate !== "after_host_message" || hostMessages > 3); + if (!crashed && armed) { + crashed = true; + throw new Error(`crash:${point}`); + } + }, + }); + await harness.client.connect(fence, planHash); + await harness.client.startTurn(spec).catch(() => {}); + await eventually( + () => + harness.errors.some((error) => + error.message.includes(`crash:${point}`), + ), + `failpoint ${point} did not disconnect the gateway client`, + ); + + await harness.client.connect(fence, planHash); + await eventually( + () => harness.dispatches === 2, + `failpoint ${point} did not recover both operations`, + ); + expect(harness.dispatches).toBe(2); + expect( + new Set( + harness.dispatchIntents.map((intent: any) => intent.operationId), + ), + ).toEqual( + new Set([spec.initialOperation.operationId, "operation-transport-2"]), + ); + expect(harness.queryIntents.every((intent) => intent.recovery)).toBe( + true, + ); + }, + ); + + test("Host recovers coordinator state as well as owed Driver-consumption credit", async () => { + let failed = false; + const harness = await setup({ + chunks: ["only"], + twoOperations: true, + hostFailpoint: (point) => { + if (point === "afterDriverDeliveryBeforeStreamAck" && !failed) { + failed = true; + throw new Error("crash:driver-consumption-ack"); + } + }, + }); + + await harness.client.connect(fence, planHash); + await harness.client.startTurn(spec); + await eventually( + () => failed, + "Driver-consumption ACK failpoint was not reached", + ); + await eventually( + () => harness.errors.length > 0, + "Driver-consumption ACK failpoint did not detach the client", + ); + await harness.client.connect(fence, planHash); + await eventually( + () => + harness.queryIntents.filter((intent) => intent.recovery).length === 2, + "Host did not query both operations while restoring owed credit", + ); + + expect(harness.dispatches).toBe(2); + expect( + harness.driver.deliveredOperationIds.filter( + (operationId) => operationId === spec.initialOperation.operationId, + ), + ).toHaveLength(1); + expect(harness.queryIntents.every((intent) => intent.recovery)).toBe(true); + for (const op of (harness.host as any).active.ops.values()) { + expect(op.owedCreditBytes).toBe(0); + expect(op.owedCreditChunks).toBe(0); + } + }); + + test("bounds replay while repeated real queries preserve one physical dispatch", async () => { + const harness = await setup({ chunks: [], settleQueries: false }); + await harness.client.connect(fence, planHash); + await harness.client.startTurn(spec); + await eventually( + () => + (harness.host as any).active?.ops.get(spec.initialOperation.operationId) + ?.receipt?.state === "executing", + "initial executing receipt was not accepted", + ); + + for (let index = 0; index < MAX_AGENT_HOST_REPLAY_FRAMES + 32; index++) + await harness.driver.query(0); + await eventually( + () => harness.queryIntents.length === MAX_AGENT_HOST_REPLAY_FRAMES + 32, + "repeated queries did not traverse the real transport", + ); + + const active = (harness.host as any).active; + expect(active.replay.length).toBeLessThanOrEqual( + MAX_AGENT_HOST_REPLAY_FRAMES, + ); + expect(active.replayBytes).toBeLessThanOrEqual(MAX_AGENT_HOST_REPLAY_BYTES); + expect(harness.dispatches).toBe(1); + }); + + test("query and cancellation receipts stay monotonic and cancellation remains uncertain", async () => { + const harness = await setup({ chunks: [], settleQueries: false }); + await harness.client.connect(fence, planHash); + await harness.client.startTurn(spec); + await eventually( + () => harness.dispatches === 1, + "operation was not dispatched", + ); + + await harness.driver.query(0); + await eventually( + () => harness.queryIntents.length === 1, + "operation query was not dispatched", + ); + await harness.driver.cancelOperation(); + await eventually( + () => harness.cancelIntents.length === 1, + "operation cancellation was not dispatched", + ); + harness.driver.finish(); + + expect(harness.dispatches).toBe(1); + expect(harness.cancelIntents).toHaveLength(1); + expect(JSON.stringify(harness.cancelIntents[0])).not.toContain("cancelled"); + expect(harness.driver.cancelCalls).toBe(0); + }); +}); diff --git a/packages/core/opensession-server/src/server/actor-transcript.ts b/packages/core/opensession-server/src/server/actor-transcript.ts index d2d98b40c1..282b9c50ae 100644 --- a/packages/core/opensession-server/src/server/actor-transcript.ts +++ b/packages/core/opensession-server/src/server/actor-transcript.ts @@ -1,7 +1,7 @@ import type { AgentTranscriptAnchorV1, AgentTranscriptReceiptRefV1, -} from "./session-kernel/transcript-protocol"; +} from "@tellahq/opensession-protocol/agent-operation"; import type { TranscriptEntry } from "./types"; import { publishTranscript } from "./transcript-bus"; import { v2SnapshotEntryWeight } from "./transcript-wire"; diff --git a/packages/core/opensession-server/src/server/agent-host-client.test.ts b/packages/core/opensession-server/src/server/agent-host-client.test.ts new file mode 100644 index 0000000000..e86d7729db --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-host-client.test.ts @@ -0,0 +1,289 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + hashAgentOperationDescriptorV1, + type AgentOperationReceiptV1, +} from "@tellahq/opensession-protocol"; +import { + AgentHostClient, + decodeAgentHostServerMessageV5, +} from "./agent-host-client"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); +}); +const digest = (character: string) => `sha256:${character.repeat(64)}` as const; +const fence = { + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, +} as const; +const descriptor = { + version: 1, + kind: "model", + stepId: "step-1", + transcript: { + throughChangeSeq: 1, + entryIds: ["entry-1"], + digest: digest("c"), + }, + modelPolicyHash: digest("d"), + adapterRequestVersion: "v1", +} as const; + +function receipt( + descriptorDigest: `sha256:${string}`, +): AgentOperationReceiptV1 { + return { + version: 1, + operationId: "operation-1", + kind: "model", + fence, + planHash: digest("a"), + authorityHash: digest("b"), + descriptorDigest, + payloadDigest: digest("e"), + actorIdentity: { + supervisorEpoch: 1, + hostId: "host-000000000001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + transcriptAnchor: descriptor.transcript, + }, + state: "prepared", + acceptedAtMs: 1, + providerRef: { adapterId: "adapter-1", adapterVersion: "v1" }, + }; +} + +async function rawHost(onFrame: (frame: any, socket: Socket) => void) { + const root = mkdtempSync(join(tmpdir(), "agent-host-client-")); + roots.push(root); + const socketPath = join(root, "host.sock"); + const server = createServer((socket) => { + let buffered = ""; + socket.on("data", (chunk) => { + buffered += chunk.toString("utf8"); + for (;;) { + const newline = buffered.indexOf("\n"); + if (newline < 0) break; + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + onFrame(JSON.parse(line), socket); + } + }); + }); + await new Promise((resolve, reject) => + server.listen(socketPath, resolve).once("error", reject), + ); + return { + socketPath, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} +const send = (socket: Socket, value: unknown) => + socket.write(`${JSON.stringify(value)}\n`); + +describe("AgentHostClient v5", () => { + test("strictly decodes Host frames", async () => { + const hello = { + t: "hello", + version: 5, + requestId: "request-1", + accepted: true, + hostId: "host-000000000001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + hostChallenge: "challenge-00000001", + } as const; + expect(await decodeAgentHostServerMessageV5(hello)).toEqual(hello); + expect( + await decodeAgentHostServerMessageV5({ ...hello, provider: "forbidden" }), + ).toBeUndefined(); + expect( + await decodeAgentHostServerMessageV5({ ...hello, version: 4 }), + ).toBeUndefined(); + const terminal = { + t: "turn_terminal", + version: 5, + requestId: "terminal-1", + fence, + hostSeq: 2, + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + result: { status: "cancelled" }, + resultDigest: digest("f"), + receiptsDigest: digest("9"), + finalAckHostSeq: 1, + operations: [], + } as const; + expect(await decodeAgentHostServerMessageV5(terminal)).toEqual(terminal); + expect(await decodeAgentHostServerMessageV5({ ...terminal, fallback: true })).toBeUndefined(); + }); + + test("dispatches descriptors through injected authority and streams opaque bytes", async () => { + const descriptorDigest = await hashAgentOperationDescriptorV1(descriptor); + const frames: any[] = []; + let dispatches = 0; + let resolveReceipt!: () => void; + const gotReceipt = new Promise((resolve) => { + resolveReceipt = resolve; + }); + const host = await rawHost((frame, socket) => { + frames.push(frame); + if (frame.t === "hello") + send(socket, { + t: "hello", + version: 5, + requestId: frame.requestId, + accepted: true, + hostId: "host-000000000001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + hostChallenge: "challenge-00000001", + }); + if (frame.t === "attach") { + send(socket, { + t: "attached", + version: 5, + requestId: frame.requestId, + fence, + planHash: digest("a"), + supervisorEpoch: 1, + mode: "fresh", + replayFromHostSeq: 1, + }); + send(socket, { + t: "operation_request", + version: 5, + requestId: "request-operation-1", + fence, + hostSeq: 1, + operationId: "operation-1", + descriptor, + descriptorDigest, + deadlineMs: Date.now() + 60_000, + }); + } + if (frame.t === "operation_receipt") resolveReceipt(); + }); + const client = new AgentHostClient({ + socketPath: host.socketPath, + obtainSignedAttach: async () => + ({ + expected: { supervisorEpoch: 1 }, + envelope: { + version: 1, + algorithm: "Ed25519", + domain: "opensession.agent-host.supervision.v2", + authorityBytes: "AQ", + signature: Buffer.alloc(64).toString("base64url"), + }, + }) as any, + dispatchOperation: async (intent) => { + dispatches++; + expect(intent.descriptor).toEqual(descriptor); + expect(intent.descriptorDigest).toBe(descriptorDigest); + return { + receipt: receipt(descriptorDigest), + chunks: [new Uint8Array([1, 2, 3])], + }; + }, + queryOperation: async () => { + throw new Error("unexpected query"); + }, + cancelOperation: async () => { + throw new Error("unexpected cancel"); + }, + acknowledgeOperationStream: async () => {}, + }); + await client.connect(fence, digest("a")); + await gotReceipt; + expect(dispatches).toBe(1); + expect( + frames.find((frame) => frame.t === "operation_stream"), + ).toMatchObject({ + operationId: "operation-1", + streamSeq: 1, + bytes: "AQID", + }); + expect( + frames.find((frame) => frame.t === "operation_receipt"), + ).toMatchObject({ ackHostSeq: 1, operationId: "operation-1" }); + client.close(); + await host.close(); + }); + + test("rejects stream gaps without dispatching", async () => { + const descriptorDigest = await hashAgentOperationDescriptorV1(descriptor); + let dispatches = 0; + let sawError!: (error: Error) => void; + const error = new Promise((resolve) => { + sawError = resolve; + }); + const host = await rawHost((frame, socket) => { + if (frame.t === "hello") + send(socket, { + t: "hello", + version: 5, + requestId: frame.requestId, + accepted: true, + hostId: "host-000000000001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + hostChallenge: "challenge-00000001", + }); + if (frame.t === "attach") { + send(socket, { + t: "attached", + version: 5, + requestId: frame.requestId, + fence, + planHash: digest("a"), + supervisorEpoch: 1, + mode: "fresh", + replayFromHostSeq: 1, + }); + send(socket, { + t: "operation_request", + version: 5, + requestId: "request-operation-2", + fence, + hostSeq: 2, + operationId: "operation-1", + descriptor, + descriptorDigest, + deadlineMs: Date.now() + 60_000, + }); + } + }); + const client = new AgentHostClient({ + socketPath: host.socketPath, + obtainSignedAttach: async () => + ({ expected: { supervisorEpoch: 1 }, envelope: {} }) as any, + dispatchOperation: async () => { + dispatches++; + return { receipt: receipt(descriptorDigest) }; + }, + queryOperation: async () => { + throw new Error("unexpected query"); + }, + cancelOperation: async () => { + throw new Error("unexpected cancel"); + }, + acknowledgeOperationStream: async () => {}, + onError: sawError, + }); + await client.connect(fence, digest("a")); + expect((await error).message).toContain("stream gap"); + expect(dispatches).toBe(0); + client.close(); + await host.close(); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-host-client.ts b/packages/core/opensession-server/src/server/agent-host-client.ts new file mode 100644 index 0000000000..6a07f85d2b --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-host-client.ts @@ -0,0 +1,964 @@ +import { connect, type Socket } from "node:net"; +import { + AGENT_HOST_PROTOCOL_VERSION, + INITIAL_AGENT_HOST_STREAM_BYTES, + INITIAL_AGENT_HOST_STREAM_CHUNKS, + decodeAgentHostAttached, + decodeAgentHostTurnTerminal, + decodeAgentHostOperationCancel, + decodeAgentHostOperationQuery, + decodeAgentHostOperationRequest, + decodeAgentHostOperationStreamAck, + decodeAgentHostTurnStarted, + hashAgentOperationReceiptV1, + hashAgentTurnTerminalReceiptsV1, + decodeAgentOperationReceiptV1, + decodeExecutorId, + isAgentTurnFence, + type AgentHostAttachResumeCursorV4, + type AgentHostChallengeDescriptorV4, + type AgentHostClientMessage, + type AgentHostOperationCancelV4, + type AgentHostOperationQueryV4, + type AgentHostOperationRequestV4, + type AgentHostOperationStreamAckV4, + type AgentHostServerMessage, + type AgentHostSignedAttachReceiptV4, + type AgentHostTurnTerminalV5, + type AgentHostTerminalOperationV5, + type AgentOperationDescriptorV1, + type AgentOperationDigest, + type AgentOperationKind, + type AgentOperationReceiptV1, + type AgentTurnFence, + type AgentTurnSpec, + decodeAgentTurnSpec, +} from "@tellahq/opensession-protocol"; +import type { SignedAgentHostSupervisionEnvelopeV1 } from "@tellahq/opensession-protocol/agent-host-supervision"; +import { + AGENT_HOST_MAX_FRAME_BYTES, + BoundedNdjsonDecoder, + encodeNdjsonFrame, +} from "../agent-host/socket-framing"; + +export type AgentHostClientFailpoint = + | "after_host_message" + | "after_coordinator_result" + | "after_stream_chunk" + | "before_receipt_write" + | "after_receipt_write"; + +export interface AgentHostCoordinatorIntent { + readonly operationId: string; + readonly fence: Readonly; + readonly kind: AgentOperationKind; + readonly descriptorDigest: AgentOperationDigest; + readonly supervisionEnvelope: SignedAgentHostSupervisionEnvelopeV1; +} +export interface AgentHostOperationStreamAckIntent { + readonly operationId: string; + readonly fence: Readonly; + readonly kind: AgentOperationKind; + readonly descriptorDigest: AgentOperationDigest; + readonly throughStreamSeq: number; +} +export interface AgentHostDispatchIntent extends AgentHostCoordinatorIntent { + readonly descriptor: AgentOperationDescriptorV1; + readonly deadlineMs: number; +} +export interface AgentHostQueryIntent extends AgentHostCoordinatorIntent { + readonly payloadDigest?: AgentOperationDigest; + readonly afterStreamSeq: number; + /** Present for exact recovery when the raw dispatch grant and payload digest are gone. */ + readonly descriptor?: AgentOperationDescriptorV1; + readonly recovery: boolean; +} +export interface AgentHostCancelIntent extends AgentHostCoordinatorIntent { + readonly cancelId: string; + readonly reason: AgentHostOperationCancelV4["reason"]; +} +export interface AgentHostOperationResult { + readonly receipt: AgentOperationReceiptV1; + readonly chunks?: AsyncIterable | Iterable; +} +export interface AgentHostQueryResult extends AgentHostOperationResult { + readonly fromStreamSeq: number; +} +export interface AgentHostCancelResult { + readonly disposition: + "not_started" | "cancelled" | "too_late" | "indeterminate"; + readonly receipt: AgentOperationReceiptV1; +} + +export interface AgentHostClientOptions { + readonly socketPath: string; + readonly timeoutMs?: number; + readonly maxFrameBytes?: number; + readonly obtainSignedAttach: ( + challenge: Readonly, + requested: Readonly<{ fence: AgentTurnFence; planHash: string }>, + ) => Promise; + /** These callbacks are the only operation authority. The client never selects an adapter, + * provider, model, MCP server, identity, or policy, and never creates a dispatch grant. */ + readonly dispatchOperation: ( + intent: Readonly, + signal: AbortSignal, + ) => Promise; + readonly queryOperation: ( + intent: Readonly, + signal: AbortSignal, + ) => Promise; + readonly cancelOperation: ( + intent: Readonly, + signal: AbortSignal, + ) => Promise; + /** Durably advances coordinator publication only after the Host Driver has + * consumed the cumulative operation stream prefix. */ + readonly acknowledgeOperationStream: ( + intent: Readonly, + ) => Promise; + /** Resolves before the exact terminal frame is acknowledged to the Host. */ + readonly onTurnTerminal?: ( + terminal: Readonly, + ) => void | Promise; + readonly onError?: (error: Error) => void; + readonly failpoint?: ( + point: AgentHostClientFailpoint, + ) => void | Promise; +} + +type ServerHello = Extract; +type ServerError = Extract; +const record = (value: unknown): value is Record => + !!value && + typeof value === "object" && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype; +const exact = (value: Record, keys: readonly string[]) => + Object.keys(value).length === keys.length && + Object.keys(value).every((key) => keys.includes(key)); +const id = (value: unknown): value is string => + typeof value === "string" && !!decodeExecutorId(value); +const sameFence = (a: Readonly, b: Readonly) => + a.sessionId === b.sessionId && + a.runId === b.runId && + a.turnId === b.turnId && + a.generation === b.generation; + +/** Strict gateway-side v5 hello codec. */ +export function decodeAgentHostServerHelloV5( + value: unknown, +): ServerHello | undefined { + if ( + !record(value) || + !exact(value, [ + "t", + "version", + "requestId", + "accepted", + "hostId", + "hostGeneration", + "hostIncarnation", + "hostChallenge", + ]) || + value.t !== "hello" || + value.version !== AGENT_HOST_PROTOCOL_VERSION || + !id(value.requestId) || + value.accepted !== true || + !id(value.hostId) || + !Number.isSafeInteger(value.hostGeneration) || + (value.hostGeneration as number) < 1 || + typeof value.hostIncarnation !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{7,255}$/.test(value.hostIncarnation) || + typeof value.hostChallenge !== "string" || + !/^[A-Za-z0-9_-]{16,256}$/.test(value.hostChallenge) + ) + return undefined; + return Object.freeze(structuredClone(value)) as unknown as ServerHello; +} +/** Strict gateway-side v5 error codec. */ +export function decodeAgentHostServerErrorV5( + value: unknown, +): ServerError | undefined { + if (!record(value)) return undefined; + const keys = [ + "t", + "version", + "requestId", + "code", + "message", + ...(value.fence === undefined ? [] : ["fence"]), + ]; + if ( + !exact(value, keys) || + value.t !== "error" || + value.version !== AGENT_HOST_PROTOCOL_VERSION || + !id(value.requestId) || + ![ + "unsupported_version", + "invalid_request", + "stale_generation", + "host_busy", + "turn_failed", + ].includes(String(value.code)) || + typeof value.message !== "string" || + (value.fence !== undefined && !isAgentTurnFence(value.fence)) + ) + return undefined; + return Object.freeze(structuredClone(value)) as unknown as ServerError; +} +/** Strict decoder for every Host-to-gateway v5 frame. */ +export async function decodeAgentHostServerMessageV5( + value: unknown, + nowMs = Date.now(), + turnDeadlineMs?: number, +): Promise { + return ( + decodeAgentHostServerHelloV5(value) ?? + decodeAgentHostAttached(value) ?? + decodeAgentHostTurnStarted(value) ?? + decodeAgentHostTurnTerminal(value) ?? + (await decodeAgentHostOperationRequest(value, nowMs, turnDeadlineMs)) ?? + decodeAgentHostOperationQuery(value) ?? + decodeAgentHostOperationCancel(value) ?? + decodeAgentHostOperationStreamAck(value) ?? + decodeAgentHostServerErrorV5(value) + ); +} + +interface PendingRequest { + expected: "hello" | "attached" | "turn_started"; + resolve: (message: AgentHostServerMessage) => void; + reject: (error: Error) => void; + timer: ReturnType; +} +interface OperationState { + readonly operationId: string; + readonly kind: AgentOperationKind; + readonly descriptorDigest: AgentOperationDigest; + readonly descriptor?: AgentOperationDescriptorV1; + payloadDigest?: AgentOperationDigest; + receipt?: AgentOperationReceiptV1; + receiptRank: number; + throughStreamSeq: number; + sentStreamSeq: number; + creditBytes: number; + creditChunks: number; + waiters: Set<() => void>; + launched: boolean; + uncertain: boolean; +} + +export class AgentHostClient { + private socket?: Socket; + private connecting?: Promise; + private fence?: AgentTurnFence; + private planHash?: string; + private receipt?: AgentHostSignedAttachReceiptV4; + private turnDeadlineMs = Number.MAX_SAFE_INTEGER; + private ready = false; + private closed = false; + private readonly pending = new Map(); + private readonly operations = new Map(); + private readonly consumedHostSeq = new Set(); + private lastHostSeq = 0; + private highestHostSeq = 0; + private requestSequence = 0; + private receiveChain = Promise.resolve(); + private generation = 0; + private terminal?: AgentHostTurnTerminalV5; + private terminalNotified = false; + private readonly terminalWaiters = new Set<{ + resolve: (terminal: Readonly) => void; + reject: (error: Error) => void; + }>(); + + constructor(private readonly options: AgentHostClientOptions) {} + + connect(fence: AgentTurnFence, planHash: string): Promise { + if (!isAgentTurnFence(fence) || !/^sha256:[a-f0-9]{64}$/.test(planHash)) + throw new Error("Invalid Agent Host attachment request"); + if (this.closed) throw new Error("Agent Host client is closed"); + if ( + this.fence && + (!sameFence(this.fence, fence) || this.planHash !== planHash) + ) + throw new Error("Agent Host client is attached to another turn"); + if (this.connecting) return this.connecting; + if (this.ready && this.socket && !this.socket.destroyed) + return Promise.resolve(); + this.fence = { ...fence }; + this.planHash = planHash; + const generation = ++this.generation; + this.connecting = this.open(generation).finally(() => { + if (generation === this.generation) this.connecting = undefined; + }); + return this.connecting; + } + + private open(generation: number): Promise { + return new Promise((resolve, reject) => { + const socket = connect(this.options.socketPath); + const decoder = new BoundedNdjsonDecoder( + this.options.maxFrameBytes ?? AGENT_HOST_MAX_FRAME_BYTES, + ); + this.socket = socket; + this.ready = false; + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + error ? reject(error) : resolve(); + }; + const fail = (error: Error) => { + if (generation !== this.generation) return; + this.disconnect(socket, error); + finish(error); + }; + socket.on( + "connect", + () => + void (async () => { + try { + const hello = await this.request("hello", { + t: "hello", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: this.nextRequestId(), + }); + if (hello.t !== "hello") + throw new Error("Agent Host hello mismatch"); + const requested = Object.freeze({ + fence: Object.freeze({ ...this.fence! }), + planHash: this.planHash!, + }); + const receipt = await this.options.obtainSignedAttach( + Object.freeze({ + hostId: hello.hostId, + hostGeneration: hello.hostGeneration, + hostIncarnation: hello.hostIncarnation, + hostChallenge: hello.hostChallenge, + }), + requested, + ); + const resume = this.resumeCursor(); + const attached = await this.request("attached", { + t: "attach", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: this.nextRequestId(), + fence: requested.fence, + planHash: requested.planHash as AgentOperationDigest, + receipt, + resume, + }); + if ( + attached.t !== "attached" || + !sameFence(attached.fence, requested.fence) || + attached.planHash !== requested.planHash || + attached.supervisorEpoch !== receipt.expected.supervisorEpoch + ) + throw new Error("Agent Host attach acknowledgement mismatch"); + this.receipt = receipt; + if (attached.mode === "recovery_required") { + const recoveryBaseline = attached.replayFromHostSeq - 1; + if (recoveryBaseline < this.lastHostSeq) + throw new Error("Agent Host recovery baseline regressed"); + this.consumedHostSeq.clear(); + this.lastHostSeq = recoveryBaseline; + this.highestHostSeq = recoveryBaseline; + } + this.ready = true; + if (this.terminal) this.writeTerminalAck(this.terminal); + finish(); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + })(), + ); + socket.on("data", (chunk) => { + try { + for (const value of decoder.push(Buffer.from(chunk))) + this.receiveChain = this.receiveChain + .then(() => this.receive(socket, generation, value)) + .catch((error) => + fail(error instanceof Error ? error : new Error(String(error))), + ); + } catch { + fail(new Error("Malformed Agent Host frame")); + } + }); + socket.on("end", () => { + try { + decoder.finish(); + } catch { + fail(new Error("Malformed Agent Host frame")); + } + }); + socket.on("error", fail); + socket.on("close", () => fail(new Error("Agent Host disconnected"))); + }); + } + + async startTurn(spec: AgentTurnSpec): Promise { + if (!this.ready || !this.fence || !this.planHash) + throw new Error("Agent Host handshake is not complete"); + const decoded = decodeAgentTurnSpec(spec); + if (!decoded || !sameFence(decoded.fence, this.fence)) + throw new Error("Invalid Agent Host turn specification"); + this.turnDeadlineMs = decoded.limits.turnDeadlineMs; + const result = await this.request("turn_started", { + t: "start_turn", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: this.nextRequestId(), + planHash: this.planHash as AgentOperationDigest, + spec: decoded, + }); + if (result.t !== "turn_started") + throw new Error("Agent Host turn start mismatch"); + } + + getTurnTerminal(): Readonly | undefined { + return this.terminal; + } + + waitForTurnTerminal(): Promise> { + if (this.terminal) return Promise.resolve(this.terminal); + if (this.closed) + return Promise.reject(new Error("Agent Host client is closed")); + return new Promise((resolve, reject) => + this.terminalWaiters.add({ resolve, reject }), + ); + } + + close(): void { + this.closed = true; + for (const waiter of this.terminalWaiters) + waiter.reject(new Error("Agent Host client is closed")); + this.terminalWaiters.clear(); + this.generation++; + if (this.socket) + this.disconnect(this.socket, new Error("Agent Host client closed")); + } + + private resumeCursor(): AgentHostAttachResumeCursorV4 | null { + if (!this.lastHostSeq && !this.operations.size) return null; + return { + lastHostSeq: this.lastHostSeq, + operations: [...this.operations.values()] + .map((op) => ({ + operationId: op.operationId, + throughStreamSeq: op.throughStreamSeq, + })) + .sort((a, b) => a.operationId.localeCompare(b.operationId)), + }; + } + + private async receive( + socket: Socket, + generation: number, + raw: unknown, + ): Promise { + if (socket !== this.socket || generation !== this.generation) return; + const message = await decodeAgentHostServerMessageV5( + raw, + Date.now(), + this.turnDeadlineMs, + ); + if (!message) throw new Error("Invalid Agent Host message"); + await this.options.failpoint?.("after_host_message"); + const pending = this.pending.get(message.requestId); + if (message.t === "error") { + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(message.requestId); + pending.reject(new Error(`${message.code}: ${message.message}`)); + } else + this.options.onError?.( + new Error(`${message.code}: ${message.message}`), + ); + return; + } + if (message.t === "hello" || message.t === "attached") { + if (pending && pending.expected === message.t) { + clearTimeout(pending.timer); + this.pending.delete(message.requestId); + pending.resolve(message); + } + return; + } + if (!this.fence || !sameFence(this.fence, message.fence)) + throw new Error("Stale Agent Host fence"); + if (message.hostSeq <= this.highestHostSeq) return; + if (message.hostSeq !== this.highestHostSeq + 1) + throw new Error("Agent Host stream gap requires recovery"); + this.highestHostSeq = message.hostSeq; + if (message.t === "turn_started") { + if (this.markConsumed(message.hostSeq)) + this.writeConsumptionAck(generation); + if (pending && pending.expected === message.t) { + clearTimeout(pending.timer); + this.pending.delete(message.requestId); + pending.resolve(message); + } + return; + } + if (message.t === "turn_terminal") { + await this.consumeTerminal(message, generation); + return; + } + if (message.t === "operation_stream_ack") { + await this.consumeStreamAck(message); + if (this.markConsumed(message.hostSeq)) + this.writeConsumptionAck(generation); + return; + } + void this.consumeIntent(message, generation).catch((error) => + this.desynchronize( + error instanceof Error ? error : new Error(String(error)), + ), + ); + } + + private async consumeIntent( + message: + | AgentHostOperationRequestV4 + | AgentHostOperationQueryV4 + | AgentHostOperationCancelV4, + generation: number, + ): Promise { + const op = this.operationFor(message); + const signal = new AbortController().signal; + if (message.t === "operation_request") { + const repeated = op.launched; + op.launched = true; + if (repeated) { + const result = await this.options.queryOperation( + this.queryIntent(op, op.throughStreamSeq, true), + signal, + ); + await this.options.failpoint?.("after_coordinator_result"); + await this.sendResult( + op, + result, + "operation_query_receipt", + message.hostSeq, + generation, + ); + op.uncertain = false; + } else { + const result = await this.options.dispatchOperation( + Object.freeze({ + ...this.intent(op), + descriptor: message.descriptor, + deadlineMs: message.deadlineMs, + }), + signal, + ); + await this.options.failpoint?.("after_coordinator_result"); + await this.sendResult( + op, + { ...result, fromStreamSeq: op.sentStreamSeq + 1 }, + "operation_receipt", + message.hostSeq, + generation, + ); + } + } else if (message.t === "operation_query") { + op.payloadDigest = message.payloadDigest; + const result = await this.options.queryOperation( + this.queryIntent(op, message.afterStreamSeq, op.uncertain), + signal, + ); + await this.options.failpoint?.("after_coordinator_result"); + await this.sendResult( + op, + result, + "operation_query_receipt", + message.hostSeq, + generation, + ); + op.uncertain = false; + } else { + const result = await this.options.cancelOperation( + Object.freeze({ + ...this.intent(op), + cancelId: message.cancelId, + reason: message.reason, + }), + signal, + ); + await this.options.failpoint?.("after_coordinator_result"); + this.acceptReceipt(op, result.receipt); + await this.writeReceipt( + { + t: "operation_cancel_receipt", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: this.nextRequestId(), + fence: this.fence!, + ackHostSeq: message.hostSeq, + operationId: op.operationId, + cancelId: message.cancelId, + disposition: result.disposition, + receipt: result.receipt, + }, + generation, + ); + if (this.markConsumed(message.hostSeq)) + this.writeConsumptionAck(generation); + op.uncertain = false; + } + } + + private operationFor( + message: + | AgentHostOperationRequestV4 + | AgentHostOperationQueryV4 + | AgentHostOperationCancelV4, + ): OperationState { + const existing = this.operations.get(message.operationId); + if (existing) { + if ( + existing.descriptorDigest !== + ("descriptorDigest" in message + ? message.descriptorDigest + : existing.descriptorDigest) || + ("kind" in message && existing.kind !== message.kind) || + (message.t === "operation_request" && + JSON.stringify(existing.descriptor) !== + JSON.stringify(message.descriptor)) + ) + throw new Error("Agent Host operation descriptor identity changed"); + return existing; + } + if (message.t === "operation_cancel") + throw new Error("Agent Host cancelled an unknown operation"); + const op: OperationState = { + operationId: message.operationId, + kind: + message.t === "operation_request" + ? message.descriptor.kind + : message.kind, + descriptorDigest: message.descriptorDigest, + ...(message.t === "operation_request" + ? { descriptor: message.descriptor } + : {}), + ...(message.t === "operation_query" + ? { payloadDigest: message.payloadDigest } + : {}), + receiptRank: -1, + throughStreamSeq: + message.t === "operation_query" ? message.afterStreamSeq : 0, + sentStreamSeq: + message.t === "operation_query" ? message.afterStreamSeq : 0, + creditBytes: INITIAL_AGENT_HOST_STREAM_BYTES, + creditChunks: INITIAL_AGENT_HOST_STREAM_CHUNKS, + waiters: new Set(), + launched: message.t === "operation_query", + uncertain: false, + }; + this.operations.set(op.operationId, op); + return op; + } + + private intent(op: OperationState): AgentHostCoordinatorIntent { + if (!this.fence || !this.receipt) + throw new Error("Agent Host supervision unavailable"); + return Object.freeze({ + operationId: op.operationId, + fence: Object.freeze({ ...this.fence }), + kind: op.kind, + descriptorDigest: op.descriptorDigest, + supervisionEnvelope: this.receipt.envelope, + }); + } + private queryIntent( + op: OperationState, + afterStreamSeq: number, + recovery: boolean, + ): AgentHostQueryIntent { + return Object.freeze({ + ...this.intent(op), + ...(op.payloadDigest ? { payloadDigest: op.payloadDigest } : {}), + afterStreamSeq, + ...(op.descriptor ? { descriptor: op.descriptor } : {}), + recovery, + }); + } + + private async sendResult( + op: OperationState, + result: AgentHostQueryResult, + type: "operation_receipt" | "operation_query_receipt", + hostSeq: number, + generation: number, + ): Promise { + this.acceptReceipt(op, result.receipt); + if (result.fromStreamSeq < 1 || result.fromStreamSeq > op.sentStreamSeq + 1) + throw new Error("Invalid operation replay cursor"); + const common = { + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: this.nextRequestId(), + fence: this.fence!, + ackHostSeq: hostSeq, + operationId: op.operationId, + receipt: result.receipt, + }; + await this.writeReceipt( + type === "operation_receipt" + ? { ...common, t: type } + : { ...common, t: type, fromStreamSeq: result.fromStreamSeq }, + generation, + ); + if (this.markConsumed(hostSeq)) this.writeConsumptionAck(generation); + let seq = result.fromStreamSeq; + for await (const raw of result.chunks ?? []) { + if ( + result.receipt.state === "settled" || + result.receipt.state === "indeterminate" + ) + throw new Error( + "Terminal operation receipt cannot precede stream data", + ); + const bytes = raw instanceof Uint8Array ? raw.slice() : undefined; + if (!bytes?.byteLength) + throw new Error("Invalid empty operation stream chunk"); + await this.awaitCredit(op, bytes.byteLength, generation); + this.write({ + t: "operation_stream", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: this.nextRequestId(), + fence: this.fence!, + operationId: op.operationId, + streamSeq: seq, + encoding: "base64url+opensession-operation-v1", + bytes: Buffer.from(bytes).toString("base64url"), + }); + op.creditBytes -= bytes.byteLength; + op.creditChunks--; + op.sentStreamSeq = Math.max(op.sentStreamSeq, seq); + seq++; + await this.options.failpoint?.("after_stream_chunk"); + } + } + + private acceptReceipt( + op: OperationState, + receipt: AgentOperationReceiptV1, + ): void { + const decoded = decodeAgentOperationReceiptV1(receipt); + if ( + !decoded || + decoded.operationId !== op.operationId || + decoded.kind !== op.kind || + decoded.descriptorDigest !== op.descriptorDigest || + !this.fence || + !sameFence(decoded.fence, this.fence) + ) + throw new Error("Coordinator returned a mismatched operation receipt"); + const rank = ["prepared", "executing", "settled", "indeterminate"].indexOf( + decoded.state, + ); + if ( + op.receipt && + (rank < op.receiptRank || + (op.receiptRank >= 2 && + JSON.stringify(decoded) !== JSON.stringify(op.receipt))) + ) + throw new Error( + "Coordinator receipt state regressed or changed terminal identity", + ); + op.receipt = decoded; + op.receiptRank = rank; + op.payloadDigest = decoded.payloadDigest; + } + + private async consumeStreamAck( + message: AgentHostOperationStreamAckV4, + ): Promise { + const op = this.operations.get(message.operationId); + if ( + !op || + message.throughStreamSeq < op.throughStreamSeq || + message.throughStreamSeq > op.sentStreamSeq + ) + throw new Error("Invalid operation stream acknowledgement"); + + // Initial and replayed cumulative ACKs have already been reflected in the + // operation cursor/window, so accepting them again must have no effect. + if (message.throughStreamSeq === op.throughStreamSeq) return; + if (!this.fence) throw new Error("Agent Host supervision unavailable"); + await this.options.acknowledgeOperationStream( + Object.freeze({ + operationId: op.operationId, + fence: Object.freeze({ ...this.fence }), + kind: op.kind, + descriptorDigest: op.descriptorDigest, + throughStreamSeq: message.throughStreamSeq, + }), + ); + op.throughStreamSeq = message.throughStreamSeq; + op.creditBytes += message.creditBytes; + op.creditChunks += message.creditChunks; + for (const wake of op.waiters) wake(); + op.waiters.clear(); + } + private async awaitCredit( + op: OperationState, + bytes: number, + generation: number, + ): Promise { + while (op.creditBytes < bytes || op.creditChunks < 1) { + if (generation !== this.generation || !this.ready) + throw new Error("Agent Host disconnected during operation stream"); + await new Promise((resolve) => op.waiters.add(resolve)); + } + } + private markConsumed(hostSeq: number): boolean { + const previous = this.lastHostSeq; + this.consumedHostSeq.add(hostSeq); + while (this.consumedHostSeq.delete(this.lastHostSeq + 1)) + this.lastHostSeq++; + return this.lastHostSeq !== previous; + } + private writeConsumptionAck(generation: number): void { + if (generation !== this.generation || !this.fence) return; + this.write({ + t: "consumption_ack", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: this.nextRequestId(), + fence: this.fence, + ackHostSeq: this.lastHostSeq, + operations: [...this.operations.values()] + .map((operation) => ({ + operationId: operation.operationId, + throughStreamSeq: operation.throughStreamSeq, + })) + .sort((a, b) => a.operationId.localeCompare(b.operationId)), + }); + } + private async consumeTerminal( + message: AgentHostTurnTerminalV5, + generation: number, + ): Promise { + if (message.finalAckHostSeq !== this.lastHostSeq) + throw new Error("Agent Host terminal acknowledgement cursor mismatch"); + const expected: AgentHostTerminalOperationV5[] = []; + for (const operation of [...this.operations.values()].sort((a, b) => + a.operationId.localeCompare(b.operationId), + )) { + if (!operation.receipt || operation.receiptRank < 2) + throw new Error( + "Agent Host terminal preceded an operation terminal receipt", + ); + expected.push({ + operationId: operation.operationId, + receiptDigest: await hashAgentOperationReceiptV1(operation.receipt), + throughStreamSeq: operation.throughStreamSeq, + }); + } + if ( + JSON.stringify(expected) !== JSON.stringify(message.operations) || + (await hashAgentTurnTerminalReceiptsV1(expected)) !== + message.receiptsDigest + ) + throw new Error("Agent Host terminal receipt projection mismatch"); + const authority = this.receipt?.expected; + if ( + authority && + (message.hostGeneration !== authority.hostGeneration || + message.hostIncarnation !== authority.hostIncarnation) + ) + throw new Error("Agent Host terminal supervision identity mismatch"); + if ( + this.terminal && + (this.terminal.resultDigest !== message.resultDigest || + this.terminal.receiptsDigest !== message.receiptsDigest || + this.terminal.hostSeq !== message.hostSeq) + ) + throw new Error("Agent Host terminal identity changed"); + if (!this.terminal) this.terminal = Object.freeze(structuredClone(message)); + if (!this.terminalNotified) { + this.terminalNotified = true; + for (const waiter of this.terminalWaiters) waiter.resolve(this.terminal); + this.terminalWaiters.clear(); + await this.options.onTurnTerminal?.(this.terminal); + } + if (this.markConsumed(message.hostSeq) && generation !== this.generation) + return; + this.writeTerminalAck(this.terminal); + } + private writeTerminalAck(terminal: AgentHostTurnTerminalV5): void { + if (!this.fence) throw new Error("Agent Host supervision unavailable"); + this.write({ + t: "turn_terminal_ack", + version: AGENT_HOST_PROTOCOL_VERSION, + requestId: this.nextRequestId(), + fence: this.fence, + ackHostSeq: terminal.hostSeq, + resultDigest: terminal.resultDigest, + receiptsDigest: terminal.receiptsDigest, + }); + } + private async writeReceipt( + message: AgentHostClientMessage, + generation: number, + ): Promise { + await this.options.failpoint?.("before_receipt_write"); + if (generation !== this.generation) + throw new Error("Agent Host receipt write became uncertain"); + this.write(message); + await this.options.failpoint?.("after_receipt_write"); + } + + private request( + expected: PendingRequest["expected"], + message: AgentHostClientMessage, + ): Promise { + return new Promise((resolve, reject) => { + const requestId = message.requestId; + const timer = setTimeout(() => { + this.pending.delete(requestId); + reject(new Error(`Agent Host ${expected} timed out`)); + }, this.options.timeoutMs ?? 5_000); + timer.unref?.(); + this.pending.set(requestId, { expected, resolve, reject, timer }); + try { + this.write(message); + } catch (error) { + clearTimeout(timer); + this.pending.delete(requestId); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + private write(message: AgentHostClientMessage): void { + if (!this.socket || this.socket.destroyed || !this.socket.writable) + throw new Error("Agent Host is disconnected"); + this.socket.write(encodeNdjsonFrame(message, this.options.maxFrameBytes)); + } + private disconnect(socket: Socket, error: Error): void { + if (socket !== this.socket) return; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + this.socket = undefined; + this.ready = false; + this.highestHostSeq = this.lastHostSeq; + for (const op of this.operations.values()) { + op.uncertain = true; + for (const wake of op.waiters) wake(); + op.waiters.clear(); + } + socket.removeAllListeners(); + socket.destroy(); + this.options.onError?.(error); + } + private desynchronize(error: Error): void { + if (this.socket) this.disconnect(this.socket, error); + else this.options.onError?.(error); + } + private nextRequestId(): string { + return `agent-host-${++this.requestSequence}-${crypto.randomUUID()}`; + } +} diff --git a/packages/core/opensession-server/src/server/agent-host-registry.ts b/packages/core/opensession-server/src/server/agent-host-registry.ts new file mode 100644 index 0000000000..092e1cad20 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-host-registry.ts @@ -0,0 +1,57 @@ +import type { AgentTurnFence } from "@tellahq/opensession-protocol"; + +export interface AgentHostRegistration { + fence: AgentTurnFence; + value: T; +} + +function lineageKey(fence: AgentTurnFence): string { + return JSON.stringify([fence.sessionId, fence.runId, fence.turnId]); +} + +function ownershipKey(fence: AgentTurnFence): string { + return JSON.stringify([ + fence.sessionId, + fence.runId, + fence.turnId, + fence.generation, + ]); +} + +/** Process-local ownership index. It deliberately has no expiry or timers. */ +export class AgentHostRegistry { + private readonly owners = new Map>(); + private readonly lineages = new Map>(); + + register(fence: AgentTurnFence, value: T): AgentHostRegistration { + const lineage = lineageKey(fence); + const current = this.lineages.get(lineage); + if (current) { + const kind = + current.fence.generation === fence.generation + ? "duplicate" + : current.fence.generation > fence.generation + ? "stale" + : "conflicting"; + throw new Error(`${kind} Agent Host ownership for ${lineage}`); + } + const registration = { fence: { ...fence }, value }; + this.owners.set(ownershipKey(fence), registration); + this.lineages.set(lineage, registration); + return registration; + } + + unregister(fence: AgentTurnFence, value?: T): boolean { + const key = ownershipKey(fence); + const current = this.owners.get(key); + if (!current || (value !== undefined && current.value !== value)) + return false; + this.owners.delete(key); + this.lineages.delete(lineageKey(fence)); + return true; + } + + find(fence: AgentTurnFence): T | undefined { + return this.owners.get(ownershipKey(fence))?.value; + } +} diff --git a/packages/core/opensession-server/src/server/agent-operation/authorized-query.ts b/packages/core/opensession-server/src/server/agent-operation/authorized-query.ts new file mode 100644 index 0000000000..46da10f2da --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/authorized-query.ts @@ -0,0 +1,196 @@ +import type { + AgentOperationDigest, + AgentOperationKind, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentTurnFence } from "@tellahq/opensession-protocol/agent-host"; + +const MAX_ID_BYTES = 512; +const DIGEST = /^sha256:[a-f0-9]{64}$/; +const encoder = new TextEncoder(); + +export interface AgentOperationQueryAuthority { + readonly planHash: AgentOperationDigest; + readonly authorityHash: AgentOperationDigest; + readonly supervisorEpoch: number; + readonly hostId: string; + readonly hostGeneration: number; + readonly hostIncarnation: string; +} + +interface AgentOperationAuthorizedQueryBase { + readonly operationId: string; + readonly kind: AgentOperationKind; + readonly fence: Readonly; + readonly descriptorDigest: AgentOperationDigest; + /** Authority freshly obtained by verifying the signed supervision envelope. */ + readonly authority: Readonly; +} + +/** + * A normal query binds the payload. Only an explicitly labelled recovery query + * may omit it; recovery remains bound to the full fence, descriptor and Host + * supervision authority. + */ +export type AgentOperationAuthorizedQuery = + | (AgentOperationAuthorizedQueryBase & { + readonly mode: "exact"; + readonly payloadDigest: AgentOperationDigest; + }) + | (AgentOperationAuthorizedQueryBase & { + readonly mode: "recovery"; + readonly payloadDigest?: AgentOperationDigest; + }); + +const BASE_KEYS = [ + "mode", + "operationId", + "kind", + "fence", + "descriptorDigest", + "authority", +] as const; + +/** Snapshots and strictly validates untrusted coordinator query input. */ +export function decodeAgentOperationAuthorizedQuery( + input: unknown, +): AgentOperationAuthorizedQuery { + const top = exactDataRecord( + input, + hasOwnDataValue(input, "payloadDigest") + ? [...BASE_KEYS, "payloadDigest"] + : BASE_KEYS, + "authorized Agent operation query", + ); + if (top.mode !== "exact" && top.mode !== "recovery") + throw new TypeError("invalid authorized Agent operation query mode"); + if (top.mode === "exact" && !hasOwnDataValue(top, "payloadDigest")) + throw new TypeError("exact Agent operation query requires payload digest"); + if (top.kind !== "model" && top.kind !== "mcp") + throw new TypeError("invalid Agent operation query kind"); + + const fenceRecord = exactDataRecord( + top.fence, + ["sessionId", "runId", "turnId", "generation"], + "Agent operation query fence", + ); + const authorityRecord = exactDataRecord( + top.authority, + [ + "planHash", + "authorityHash", + "supervisorEpoch", + "hostId", + "hostGeneration", + "hostIncarnation", + ], + "Agent operation query authority", + ); + const fence = Object.freeze({ + sessionId: validText(fenceRecord.sessionId, "session ID"), + runId: validText(fenceRecord.runId, "run ID"), + turnId: validText(fenceRecord.turnId, "turn ID"), + generation: nonnegative(fenceRecord.generation, "fence generation"), + }); + const authority = Object.freeze({ + planHash: digest(authorityRecord.planHash, "plan hash"), + authorityHash: digest(authorityRecord.authorityHash, "authority hash"), + supervisorEpoch: positive( + authorityRecord.supervisorEpoch, + "supervisor epoch", + ), + hostId: validText(authorityRecord.hostId, "Host ID"), + hostGeneration: positive(authorityRecord.hostGeneration, "Host generation"), + hostIncarnation: validText( + authorityRecord.hostIncarnation, + "Host incarnation", + ), + }); + const common = { + mode: top.mode, + operationId: validText(top.operationId, "operation ID"), + kind: top.kind, + fence, + descriptorDigest: digest(top.descriptorDigest, "descriptor digest"), + authority, + }; + return Object.freeze( + hasOwnDataValue(top, "payloadDigest") + ? { + ...common, + payloadDigest: digest(top.payloadDigest, "payload digest"), + } + : common, + ) as AgentOperationAuthorizedQuery; +} + +function exactDataRecord( + value: unknown, + expectedKeys: readonly string[], + name: string, +): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw new TypeError(`invalid ${name}`); + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if ( + keys.length !== expectedKeys.length || + keys.some((key) => typeof key !== "string" || !expectedKeys.includes(key)) + ) + throw new TypeError(`invalid ${name}`); + for (const key of keys as string[]) { + const descriptor = descriptors[key]; + if ( + !descriptor || + !("value" in descriptor) || + !descriptor.enumerable || + descriptor.value === undefined + ) + throw new TypeError(`invalid ${name}`); + } + return Object.fromEntries( + (keys as string[]).map((key) => [key, descriptors[key]!.value]), + ); +} + +function hasOwnDataValue(value: unknown, key: string): boolean { + if (!value || typeof value !== "object") return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return ( + !!descriptor && "value" in descriptor && descriptor.value !== undefined + ); +} + +function validText(value: unknown, name: string): string { + if ( + typeof value !== "string" || + !value || + encoder.encode(value).byteLength > MAX_ID_BYTES || + value.trim() !== value || + /[\u0000-\u001f\u007f]/u.test(value) + ) + throw new TypeError(`invalid ${name}`); + return value; +} + +function digest(value: unknown, name: string): AgentOperationDigest { + if (typeof value !== "string" || !DIGEST.test(value)) + throw new TypeError(`invalid ${name}`); + return value as AgentOperationDigest; +} + +function nonnegative(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) + throw new TypeError(`invalid ${name}`); + return value as number; +} + +function positive(value: unknown, name: string): number { + const result = nonnegative(value, name); + if (result < 1) throw new TypeError(`invalid ${name}`); + return result; +} diff --git a/packages/core/opensession-server/src/server/agent-operation/composition.test.ts b/packages/core/opensession-server/src/server/agent-operation/composition.test.ts new file mode 100644 index 0000000000..3eab581ad3 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/composition.test.ts @@ -0,0 +1,436 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + hashAgentMcpArgumentsV1, + hashAgentMcpPayloadV1, + hashAgentModelPayloadV1, + hashAgentOperationDescriptorV1, + type AgentOperationDigest, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentHostSupervisionAuthorityV2 } from "@tellahq/opensession-protocol/agent-host"; +import { TranscriptStore } from "../transcript-store"; +import type { AgentOperationRequest } from "../session-kernel/agent-operation-protocol"; +import { createAgentOperationComposition } from "./composition"; +import { encodeAgentGatewayPolicyHandle } from "./grants"; +import { + MCP_AGENT_OPERATION_ADAPTER_ID, + MCP_AGENT_OPERATION_ADAPTER_VERSION, +} from "./mcp-adapter"; +import { + PI_MODEL_AGENT_OPERATION_ADAPTER_ID, + PI_MODEL_AGENT_OPERATION_ADAPTER_VERSION, +} from "./pi-model-adapter"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); +}); +const d = (c: string) => `sha256:${c.repeat(64)}` as AgentOperationDigest; +const freeze = (value: T): Readonly => Object.freeze(value); +const fence = freeze({ + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, +}); +const anchor = freeze({ + throughChangeSeq: 0, + entryIds: freeze([] as string[]), + digest: d("a"), +}); +const authority: AgentHostSupervisionAuthorityV2 = { + version: 2, + fence, + planHash: d("b"), + hostId: "host-000000000001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + supervisorEpoch: 1, + kernelServiceEpoch: "kernel-epoch-0001", + hostChallenge: "challenge-00000001", + audience: "opensession-agent-host", + purpose: "agent-host-supervision", + issuedAtMs: 1, + expiresAtMs: 1_000_000, + nonce: "nonce-000000000001", + keyId: "key-0000000000001", +}; +const envelope = { + version: 1, + algorithm: "Ed25519", + domain: "opensession.agent-host.supervision.v2", + authorityBytes: "AQ", + signature: Buffer.alloc(64).toString("base64url"), +} as const; + +function actor() { + const receipts = new Map(); + const events: string[] = []; + return { + events, + client: { + async decideAgentOperationAsync(request: AgentOperationRequest) { + const key = request.identity.operationId; + if (request.op === "admit") { + events.push("admit"); + const receipt = receipts.get(key) ?? { + identity: request.identity, + sequence: 1, + state: "admitted", + admittedAtMs: 10, + }; + receipts.set(key, receipt); + return { accepted: true, replayed: receipts.has(key), receipt }; + } + if (request.op === "settle" || request.op === "indeterminate") { + events.push(request.op); + const receipt = { + identity: request.identity, + sequence: 2, + state: request.op === "settle" ? "settled" : "indeterminate", + admittedAtMs: 10, + terminalAtMs: 20, + gatewayReceiptDigest: request.gatewayReceiptDigest, + outputDigest: request.outputDigest, + outcomeCode: request.outcomeCode, + transcriptReceipts: request.transcriptReceipts, + ...(request.identity.kind === "model" + ? { pendingToolUseEntryIds: request.pendingToolUseEntryIds! } + : {}), + }; + receipts.set(key, receipt); + return { accepted: true, replayed: false, receipt }; + } + if (request.op === "query") { + const receipt = receipts.get(key); + return receipt + ? { accepted: true, replayed: true, receipt } + : { accepted: false, reason: "not_found" }; + } + if (request.op === "cancel") { + events.push("cancel"); + return { + accepted: true, + replayed: false, + intent: { + identity: request.identity, + cancelId: request.cancelId, + reason: request.reason, + disposition: "requested", + }, + }; + } + throw new Error("unexpected actor operation"); + }, + }, + }; +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "agent-composition-")); + roots.push(root); + const store = new TranscriptStore(join(root, "transcript.sqlite")); + const kernel = actor(); + let modelPhysical = 0; + let entropy = 0; + const composition = createAgentOperationComposition({ + ledger: { dbPath: join(root, "operations.sqlite") }, + grants: { + now: () => 10, + entropy: () => Buffer.alloc(32, ++entropy).toString("base64url"), + }, + actor: kernel.client as any, + transcript: { + store, + render: async (identity, result) => + freeze({ + entries: freeze([ + freeze({ + id: `${identity.operationId}-output`, + type: + identity.kind === "model" + ? ("assistant" as const) + : ("tool_result" as const), + content: JSON.stringify(result.transcript), + timestamp: "2026-08-23T00:00:00.000Z", + }), + ]), + ...(identity.kind === "model" + ? { pendingToolUseEntryIds: freeze([] as string[]) } + : {}), + }), + authenticateReservation: async (_identity, reservation) => reservation, + }, + piInvocations: { now: () => 10 }, + piExecutor: { + async execute({ publish }) { + modelPhysical++; + await publish(new TextEncoder().encode("delta")); + return { + outcome: freeze({ status: "succeeded" as const, code: "ok" }), + transcript: freeze({ text: "model done" }), + }; + }, + }, + decodeMcpPayload: (payload) => { + if (!payload || typeof payload !== "object" || !("arguments" in payload)) + return undefined; + const argumentsBytes = new TextEncoder().encode( + JSON.stringify((payload as any).arguments), + ); + return freeze({ + kind: "mcp" as const, + value: payload, + canonicalBytes: new TextEncoder().encode(JSON.stringify(payload)), + canonicalArgumentsBytes: argumentsBytes, + }); + }, + gateway: { + now: () => 20, + resolveTranscriptAnchor: async () => anchor, + appendIndeterminateNotice: async () => { + throw new Error("unexpected recovery"); + }, + }, + verifySupervision: async () => ({ authority, authorityHash: d("c") }), + hostClient: { + socketPath: join(root, "host.sock"), + obtainSignedAttach: async () => { + throw new Error("not connected in composition test"); + }, + }, + }); + return { + root, + store, + kernel, + composition, + modelPhysical: () => modelPhysical, + }; +} + +async function settle( + composition: ReturnType, + dispatch: any, + query: any, +) { + const callbacks = (composition.hostClient as any).options; + const first = await callbacks.dispatchOperation( + dispatch, + new AbortController().signal, + ); + if (first.chunks) { + let consumed = false; + for await (const _chunk of first.chunks) { + consumed = true; + break; + } + if (consumed) + await callbacks.acknowledgeOperationStream({ + ...dispatch, + throughStreamSeq: 1, + }); + } + for (let attempt = 0; attempt < 20; attempt++) { + await new Promise((resolve) => setImmediate(resolve)); + const result = await callbacks.queryOperation( + query, + new AbortController().signal, + ); + if ( + result.receipt.state === "settled" || + result.receipt.state === "indeterminate" + ) + return result; + } + return callbacks.queryOperation(query, new AbortController().signal); +} + +describe("detached Agent operation boot composition", () => { + test("model and MCP complete through the real turn-owned adapters with exact transcript receipts", async () => { + const f = fixture(); + await f.composition.start(); + + const modelDescriptor = freeze({ + version: 1 as const, + kind: "model" as const, + stepId: "step-model", + transcript: anchor, + modelPolicyHash: d("d"), + adapterRequestVersion: "v1" as const, + }); + const modelDescriptorDigest = + await hashAgentOperationDescriptorV1(modelDescriptor); + const bindingRef = Buffer.alloc(32, 1).toString("base64url"); + const invocationRef = Buffer.alloc(32, 2).toString("base64url"); + f.composition.piBindings.register({ + fence, + bindingRef, + binding: { model: { provider: "test", id: "model" } } as any, + descriptorDigest: modelDescriptorDigest, + modelPolicyHash: modelDescriptor.modelPolicyHash, + modelIdentity: { provider: "test", id: "model" }, + }); + const invocation = freeze({ prompt: "private prompt" }); + const invocationBytes = new TextEncoder().encode( + JSON.stringify(invocation), + ); + const registration = f.composition.piInvocations.register({ + fence, + operationId: "operation-model", + bindingRef, + invocationRef, + descriptorDigest: modelDescriptorDigest, + invocation, + canonicalBytes: invocationBytes, + deadlineMs: 500, + }); + const modelPlan = { + operationId: "operation-model", + fence, + kind: "model" as const, + descriptor: modelDescriptor, + descriptorDigest: modelDescriptorDigest, + payload: registration.reference, + canonicalPayloadBytes: invocationBytes, + transcriptAnchor: anchor, + adapterId: PI_MODEL_AGENT_OPERATION_ADAPTER_ID, + adapterVersion: PI_MODEL_AGENT_OPERATION_ADAPTER_VERSION, + deadlineMs: 500, + policyHandle: encodeAgentGatewayPolicyHandle("modelpolicy0000001"), + }; + await f.composition.service.registerPlan(modelPlan); + const modelPayloadDigest = await hashAgentModelPayloadV1(invocationBytes); + const modelDispatch = { + operationId: modelPlan.operationId, + fence, + kind: modelPlan.kind, + descriptorDigest: modelDescriptorDigest, + supervisionEnvelope: envelope, + descriptor: modelDescriptor, + deadlineMs: 500, + }; + const model = await settle(f.composition, modelDispatch, { + ...modelDispatch, + payloadDigest: modelPayloadDigest, + afterStreamSeq: 1, + recovery: false, + }); + expect(model.receipt.state).toBe("settled"); + expect(model.receipt.transcriptRefs?.[0]?.entryIds).toEqual([ + "operation-model-output", + ]); + expect(f.modelPhysical()).toBe(1); + + const g = fixture(); + await g.composition.start(); + const args = freeze({ query: "safe" }); + const mcpPayload = freeze({ arguments: args }); + const argumentsBytes = new TextEncoder().encode(JSON.stringify(args)); + const payloadBytes = new TextEncoder().encode(JSON.stringify(mcpPayload)); + const mcpDescriptor = freeze({ + version: 1 as const, + kind: "mcp" as const, + toolUseEntryId: "entry-tool-use", + toolUseId: "tool-use-1", + server: "search-server", + tool: "search", + argumentsDigest: await hashAgentMcpArgumentsV1(argumentsBytes), + adapterRequestVersion: "v1" as const, + }); + const mcpDescriptorDigest = + await hashAgentOperationDescriptorV1(mcpDescriptor); + let mcpPhysical = 0; + g.composition.mcpRuntimes.register(fence, { + catalog: async () => [ + { id: "search-server_search", server: "search-server", name: "search" }, + ], + callExact: async () => { + mcpPhysical++; + return { content: [{ type: "text", text: "tool done" }] }; + }, + close: async () => {}, + } as any); + const mcpPlan = { + operationId: "operation-mcp", + fence, + kind: "mcp" as const, + descriptor: mcpDescriptor, + descriptorDigest: mcpDescriptorDigest, + payload: mcpPayload, + canonicalPayloadBytes: payloadBytes, + transcriptAnchor: anchor, + toolUseEntryId: mcpDescriptor.toolUseEntryId, + adapterId: MCP_AGENT_OPERATION_ADAPTER_ID, + adapterVersion: MCP_AGENT_OPERATION_ADAPTER_VERSION, + deadlineMs: 500, + policyHandle: encodeAgentGatewayPolicyHandle("mcppolicy000000001"), + }; + await g.composition.service.registerPlan(mcpPlan); + const mcpPayloadDigest = await hashAgentMcpPayloadV1(payloadBytes); + const mcpDispatch = { + operationId: mcpPlan.operationId, + fence, + kind: mcpPlan.kind, + descriptorDigest: mcpDescriptorDigest, + supervisionEnvelope: envelope, + descriptor: mcpDescriptor, + deadlineMs: 500, + }; + const mcp = await settle(g.composition, mcpDispatch, { + ...mcpDispatch, + payloadDigest: mcpPayloadDigest, + afterStreamSeq: 0, + recovery: false, + }); + expect(mcp.receipt.state).toBe("settled"); + expect(mcp.receipt.transcriptRefs?.[0]?.entryIds).toEqual([ + "operation-mcp-output", + ]); + expect(mcpPhysical).toBe(1); + expect(f.composition.readinessFeed()).toMatchObject({ + gatewayOperationLedger: { schemaVersion: 2, recoverActiveComplete: true }, + infrastructureFallback: false, + }); + await Promise.all([f.composition.close(), g.composition.close()]); + f.store.close(); + g.store.close(); + }); + + test("startup gates admission, Host callbacks fail wrong authority before physical work, and close is idempotent", async () => { + const f = fixture(); + expect( + f.composition.readinessFeed().gatewayOperationLedger + .recoverActiveComplete, + ).toBe(false); + await expect(f.composition.service.registerPlan({} as any)).rejects.toThrow( + "not ready", + ); + await Promise.all([f.composition.start(), f.composition.start()]); + const callbacks = (f.composition.hostClient as any).options; + await expect( + callbacks.dispatchOperation( + { + operationId: "missing", + fence, + kind: "model", + descriptorDigest: d("f"), + supervisionEnvelope: envelope, + descriptor: {}, + deadlineMs: 1, + }, + new AbortController().signal, + ), + ).rejects.toThrow("plan mismatch"); + expect(f.modelPhysical()).toBe(0); + await Promise.all([f.composition.close(), f.composition.close()]); + expect(f.composition.readinessFeed()).toMatchObject({ + gatewayOperationLedger: { recoverActiveComplete: false }, + infrastructureFallback: false, + }); + f.store.close(); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/composition.ts b/packages/core/opensession-server/src/server/agent-operation/composition.ts new file mode 100644 index 0000000000..bd97c17d5c --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/composition.ts @@ -0,0 +1,245 @@ +import { + AgentHostClient, + type AgentHostClientOptions, +} from "../agent-host-client"; +import type { SessionKernelActorClient } from "../session-kernel/actor-client"; +import { + AgentGatewayGrantRegistry, + type AgentGatewayGrantRegistryOptions, +} from "./grants"; +import type { + AgentGatewayDecodedPayload, + AgentOperationGatewayOptions, +} from "./gateway"; +import { AgentOperationKernelFacade } from "./kernel-facade"; +import { + createMcpAgentOperationAdapter, + MCP_AGENT_OPERATION_ADAPTER_ID, + MCP_AGENT_OPERATION_ADAPTER_VERSION, + MCP_AGENT_OPERATION_RECONCILER, + McpTurnRuntimeRegistry, +} from "./mcp-adapter"; +import { + createPiModelAgentOperationAdapter, + PI_MODEL_AGENT_OPERATION_ADAPTER_ID, + PI_MODEL_AGENT_OPERATION_ADAPTER_VERSION, + PI_MODEL_AGENT_OPERATION_RECONCILER, + PiRuntimeBindingRegistry, + type PiBoundModelExecutor, +} from "./pi-model-adapter"; +import { + decodePiModelGatewayPayload, + decodePiModelOperationReferenceV1, + PiModelInvocationRegistry, + type PiModelInvocationRegistryOptions, +} from "./pi-model-operation"; +import { + AgentOperationService, + type AgentOperationServiceOptions, +} from "./service"; +import { + SQLiteAgentOperationLedger, + type SQLiteAgentOperationLedgerOptions, +} from "./sqlite-ledger"; +import { + AgentOperationTranscriptFacade, + type AgentOperationTranscriptFacadeOptions, +} from "./transcript-facade"; + +const GATEWAY_OPERATION_LEDGER_SCHEMA_VERSION = 2; + +type ActorClient = Pick; +type ServiceGatewayOptions = Omit< + AgentOperationGatewayOptions, + | "ledger" + | "grants" + | "admission" + | "adapterFor" + | "decodePayload" + | "appendTerminal" + | "reconcilerFor" + | "beginLiveExecution" + | "verifySupervision" +>; +type HostClientBootOptions = Omit< + AgentHostClientOptions, + | "dispatchOperation" + | "queryOperation" + | "cancelOperation" + | "acknowledgeOperationStream" +>; + +export interface AgentOperationCompositionOptions { + readonly ledger: SQLiteAgentOperationLedgerOptions; + readonly grants?: AgentGatewayGrantRegistryOptions; + readonly actor: ActorClient; + readonly transcript: AgentOperationTranscriptFacadeOptions; + readonly piExecutor: PiBoundModelExecutor; + readonly piInvocations?: PiModelInvocationRegistryOptions; + /** Strict turn-owned MCP decoder. It must return canonical full-payload and arguments bytes. */ + readonly decodeMcpPayload: ( + payload: unknown, + ) => AgentGatewayDecodedPayload | undefined; + readonly gateway: ServiceGatewayOptions; + readonly verifySupervision: AgentOperationServiceOptions["verifySupervision"]; + readonly hostClient: HostClientBootOptions; + readonly closeTimeoutMs?: number; + readonly scheduleTimeout?: AgentOperationServiceOptions["scheduleTimeout"]; + readonly maxPlans?: number; + readonly maxCanonicalPayloadBytes?: number; +} + +export interface AgentOperationReadinessFeed { + readonly gatewayOperationLedger: Readonly<{ + schemaVersion: 2; + recoverActiveComplete: boolean; + }>; + readonly boundedRegistries: Readonly<{ + gatewayGrants: true; + gatewayOperations: true; + }>; + readonly infrastructureFallback: false; + readonly capabilities: Readonly<{ + deletion: true; + recovery: true; + streamAck: true; + }>; +} + +/** + * Import-inert production composition for the detached Agent operation path. + * Construction opens only the explicitly injected SQLite ledger. It does not + * connect the Host client, schedule work, or change the production route. + */ +export class AgentOperationComposition { + readonly ledger: SQLiteAgentOperationLedger; + readonly grants: AgentGatewayGrantRegistry; + readonly kernel: AgentOperationKernelFacade; + readonly transcript: AgentOperationTranscriptFacade; + readonly piBindings: PiRuntimeBindingRegistry; + readonly piInvocations: PiModelInvocationRegistry; + readonly mcpRuntimes: McpTurnRuntimeRegistry; + readonly service: AgentOperationService; + readonly hostClient: AgentHostClient; + + constructor(options: AgentOperationCompositionOptions) { + this.ledger = new SQLiteAgentOperationLedger(options.ledger); + this.grants = new AgentGatewayGrantRegistry(options.grants); + this.kernel = new AgentOperationKernelFacade(options.actor); + this.transcript = new AgentOperationTranscriptFacade(options.transcript); + this.piBindings = new PiRuntimeBindingRegistry(); + this.piInvocations = new PiModelInvocationRegistry(options.piInvocations); + this.mcpRuntimes = new McpTurnRuntimeRegistry(); + + const piAdapter = createPiModelAgentOperationAdapter( + this.piBindings, + this.piInvocations, + options.piExecutor, + ); + const mcpAdapter = createMcpAgentOperationAdapter(this.mcpRuntimes); + let service!: AgentOperationService; + this.hostClient = new AgentHostClient({ + ...options.hostClient, + dispatchOperation: (intent, signal) => + service.dispatchOperation(intent, signal), + queryOperation: (intent, signal) => + service.queryOperation(intent, signal), + cancelOperation: (intent, signal) => + service.cancelOperation(intent, signal), + acknowledgeOperationStream: (intent) => + service.acknowledgeOperationStream(intent), + }); + + const closeOwners = [ + () => this.hostClient.close(), + () => this.ledger.close(), + ] as const; + service = new AgentOperationService({ + grants: this.grants, + gateway: { + ...options.gateway, + ledger: this.ledger, + admission: this.kernel, + adapterFor: (request) => + request.kind === "model" ? piAdapter : mcpAdapter, + decodePayload: (kind, payload, request) => { + if (kind === "mcp") { + const decoded = options.decodeMcpPayload(payload); + return decoded?.kind === "mcp" ? decoded : undefined; + } + const reference = decodePiModelOperationReferenceV1(payload); + if (!reference) return undefined; + return decodePiModelGatewayPayload( + this.piInvocations, + { + fence: request.fence, + operationId: request.operationId, + descriptorDigest: request.descriptorDigest, + bindingRef: reference.bindingRef, + }, + payload, + ); + }, + appendTerminal: (identity, result) => + this.transcript.appendTerminal(identity, result), + reconcilerFor: (record) => { + if ( + record.adapterId === PI_MODEL_AGENT_OPERATION_ADAPTER_ID && + record.adapterVersion === PI_MODEL_AGENT_OPERATION_ADAPTER_VERSION + ) + return PI_MODEL_AGENT_OPERATION_RECONCILER; + if ( + record.adapterId === MCP_AGENT_OPERATION_ADAPTER_ID && + record.adapterVersion === MCP_AGENT_OPERATION_ADAPTER_VERSION + ) + return MCP_AGENT_OPERATION_RECONCILER; + return undefined; + }, + }, + verifySupervision: options.verifySupervision, + authorizedReceiptReader: (query) => this.ledger.queryAuthorized(query), + cancellation: this.kernel, + closeOwners, + closeTimeoutMs: options.closeTimeoutMs, + scheduleTimeout: options.scheduleTimeout, + maxPlans: options.maxPlans, + maxCanonicalPayloadBytes: options.maxCanonicalPayloadBytes, + }); + this.service = service; + } + + start(): Promise { + return this.service.start(); + } + + close(): Promise { + return this.service.close(); + } + + readinessFeed(): AgentOperationReadinessFeed { + const health = this.service.healthSnapshot(); + return Object.freeze({ + gatewayOperationLedger: Object.freeze({ + schemaVersion: GATEWAY_OPERATION_LEDGER_SCHEMA_VERSION, + recoverActiveComplete: + health.ready && !health.recovering && !health.failed, + }), + boundedRegistries: Object.freeze({ + gatewayGrants: true as const, + gatewayOperations: true as const, + }), + infrastructureFallback: false as const, + capabilities: Object.freeze({ + deletion: true as const, + recovery: true as const, + streamAck: true as const, + }), + }); + } +} + +export function createAgentOperationComposition( + options: AgentOperationCompositionOptions, +): AgentOperationComposition { + return new AgentOperationComposition(options); +} diff --git a/packages/core/opensession-server/src/server/agent-operation/deletion-coordinator.test.ts b/packages/core/opensession-server/src/server/agent-operation/deletion-coordinator.test.ts new file mode 100644 index 0000000000..7e7d74f1a1 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/deletion-coordinator.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, test } from "bun:test"; +import { + AGENT_DELETION_PHASES, + AGENT_DELETION_RECEIPT_RETENTION_MS, + AGENT_DELETION_SCHEMA_VERSION, + AgentDeletionCoordinator, + AgentDeletionReceiptError, + type AgentDeletionDigest, + type AgentDeletionDurableStore, + type AgentDeletionHostReceipt, + type AgentDeletionHostTarget, + type AgentDeletionIdentity, + type AgentDeletionOwners, + type AgentDeletionRecord, +} from "./deletion-coordinator"; + +type ActivePhase = Exclude<(typeof AGENT_DELETION_PHASES)[number], "completed">; +const activePhases = AGENT_DELETION_PHASES.slice(0, -1) as ActivePhase[]; +const identity = { sessionId: "os-session", deleteRequestId: "delete-exact-1" }; +const digest = (letter = "a") => `sha256:${letter.repeat(64)}` as AgentDeletionDigest; +const clone = (value: T): T => structuredClone(value); + +class MemoryStore implements AgentDeletionDurableStore { + record?: AgentDeletionRecord; + casCalls = 0; + failCas?: number; + cleanupBefore?: number; + + async claim(input: AgentDeletionIdentity & { schemaVersion: 1; createdAtMs: number }) { + this.record ??= { + ...input, + revision: 0, + updatedAtMs: input.createdAtMs, + phase: "stop_and_detach", + receipts: [], + }; + return clone(this.record); + } + + async load() { + if (!this.record) throw new Error("missing"); + return clone(this.record); + } + + async compareAndSwap(expectedRevision: number, next: AgentDeletionRecord) { + this.casCalls++; + if (this.casCalls === this.failCas) throw new Error("crash"); + if (!this.record || this.record.revision !== expectedRevision) { + return { status: "conflict", record: clone(this.record!) }; + } + this.record = clone(next); + return { status: "committed", record: clone(this.record) }; + } + + async cleanupCompleted(before: number) { + this.cleanupBefore = before; + return 1; + } +} + +function ownerReceipt

( + phase: P, + id: AgentDeletionIdentity = identity, +) { + return { schemaVersion: 1 as const, ...id, phase, digest: digest("b") }; +} + +function hostReceipt(target: AgentDeletionHostTarget, id: AgentDeletionIdentity = identity): AgentDeletionHostReceipt { + return { + schemaVersion: 1, + ...id, + ...target, + disposition: "tombstoned", + tombstoneDigest: digest("c"), + }; +} + +function fixture(options: { + hosts?: readonly AgentDeletionHostTarget[]; + fail?: string; + onCall?: (name: string) => Promise | void; +} = {}) { + const calls: string[] = []; + const hosts = [...(options.hosts ?? [])]; + const invoke = async (name: string, value: T): Promise => { + calls.push(name); + await options.onCall?.(name); + if (options.fail === name) throw new Error(`${name} unavailable`); + return value; + }; + const owners: AgentDeletionOwners = { + stopLiveTurnAndDetachRoute: (id) => invoke("stop", ownerReceipt("stop_and_detach", id)), + enumeratePinsAndHosts: (id) => invoke("enumerate", { + schemaVersion: 1, + ...id, + digest: digest("d"), + pins: hosts.map(({ generationId, runGeneration }) => ({ generationId, runGeneration })), + hosts, + }), + deleteFromHost: ({ target, ...id }) => invoke(`host:${target.hostId}`, hostReceipt(target, id)), + settleGatewayPrivateRegistries: (id) => invoke("registries", ownerReceipt("settle_gateway", id)), + settleOperationLedger: (id) => invoke("ledger", ownerReceipt("settle_gateway", id)), + verifyHostDeletionAndReleasePins: ({ pins, hostReceipts, ...id }) => invoke("release", { + schemaVersion: 1, + ...id, + phase: "verify_and_release_pins" as const, + digest: digest("e"), + pins, + hostReceipts, + }), + deleteTranscriptAndResetWake: (id) => invoke("transcript", ownerReceipt("delete_transcript", id)), + tombstoneKernelAndFinishArtifacts: (id) => invoke("kernel", ownerReceipt("finish_kernel", id)), + }; + return { calls, owners }; +} + +for (const state of ["idle", "active", "reconnecting", "recovering"] as const) { + test(`deletes an ${state} session in owner order and replays exact success`, async () => { + const store = new MemoryStore(); + const { calls, owners } = fixture(); + const coordinator = new AgentDeletionCoordinator(store, owners, () => 100); + const first = await coordinator.deleteSession(identity); + expect(await coordinator.deleteSession(identity)).toEqual(first); + expect(calls).toEqual([ + "stop", "enumerate", "registries", "ledger", "release", "transcript", "kernel", + ]); + expect(store.record?.revision).toBe(7); + expect(store.record?.receipts.map(({ phase }) => phase)).toEqual(activePhases); + expect(store.record?.receipts.every((receipt) => + !Object.hasOwn(receipt, "evidence") && receipt.evidenceDigests.every((value) => value.startsWith("sha256:")), + )).toBe(true); + }); +} + +test("two process coordinators converge through stale CAS without phase regression", async () => { + const store = new MemoryStore(); + let waiting = 0; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const { owners } = fixture({ + onCall: async (name) => { + if (name !== "stop") return; + waiting++; + if (waiting === 2) release(); + await gate; + }, + }); + const first = new AgentDeletionCoordinator(store, owners, () => 10); + const second = new AgentDeletionCoordinator(store, owners, () => 11); + const [a, b] = await Promise.all([first.deleteSession(identity), second.deleteSession(identity)]); + expect(a).toEqual(b); + expect(store.record?.phase).toBe("completed"); + expect(store.record?.receipts.map(({ phase }) => phase)).toEqual(activePhases); +}); + +test("failure checkpoint from stale coordinator cannot overwrite completed state", async () => { + const store = new MemoryStore(); + const good = fixture(); + const stale = fixture(); + let rejectStop!: (error: Error) => void; + stale.owners.stopLiveTurnAndDetachRoute = () => new Promise((_, reject) => { rejectStop = reject; }); + const staleRun = new AgentDeletionCoordinator(store, stale.owners, () => 1).deleteSession(identity); + await Promise.resolve(); + await new AgentDeletionCoordinator(store, good.owners, () => 2).deleteSession(identity); + rejectStop(new Error("late owner failure")); + await expect(staleRun).rejects.toThrow("late owner failure"); + expect(store.record?.phase).toBe("completed"); + expect(store.record?.lastFailure).toBeUndefined(); +}); + +test("crash after owner before CAS replays only the same destination idempotency identity", async () => { + const store = new MemoryStore(); + store.failCas = 1; + const physical = new Set(); + const replayed: string[] = []; + const base = fixture(); + const original = base.owners.stopLiveTurnAndDetachRoute; + base.owners.stopLiveTurnAndDetachRoute = async (id) => { + const key = `stop:${id.sessionId}:${id.deleteRequestId}`; + if (physical.has(key)) replayed.push(key); + physical.add(key); + return original(id); + }; + await expect(new AgentDeletionCoordinator(store, base.owners, () => 1).deleteSession(identity)).rejects.toThrow("crash"); + store.failCas = undefined; + await new AgentDeletionCoordinator(store, base.owners, () => 2).deleteSession(identity); + expect(physical.size).toBe(1); + expect(replayed).toEqual([`stop:${identity.sessionId}:${identity.deleteRequestId}`]); +}); + +test("all-settles Hosts, durably retains partial receipts, and retries only unresolved targets", async () => { + const hosts: AgentDeletionHostTarget[] = [ + { hostId: "host-a", ledgerState: "active", generationId: "gen-1", runGeneration: 1 }, + { hostId: "host-b", ledgerState: "draining", generationId: "gen-2", runGeneration: 2 }, + { hostId: "host-c", ledgerState: "blocked", generationId: "gen-3", runGeneration: 3 }, + ]; + const store = new MemoryStore(); + const first = fixture({ hosts }); + first.owners.deleteFromHost = async ({ target, ...id }) => { + first.calls.push(`host:${target.hostId}`); + if (target.hostId === "host-b") throw new Error("host-b unavailable"); + return hostReceipt(target, id); + }; + const coordinator = new AgentDeletionCoordinator(store, first.owners, () => 10); + await expect(coordinator.deleteSession(identity)).rejects.toThrow("host-b unavailable"); + expect(first.calls).toEqual(["stop", "enumerate", "host:host-a", "host:host-b", "host:host-c"]); + expect(store.record?.hostReceipts?.map(({ hostId }) => hostId)).toEqual(["host-a", "host-c"]); + expect(coordinator.readiness(store.record)).toEqual({ + ready: false, retryable: true, failedPhase: "delete_hosts", + }); + + const recovery = fixture({ hosts }); + await new AgentDeletionCoordinator(store, recovery.owners, () => 20).deleteSession(identity); + expect(recovery.calls.filter((call) => call.startsWith("host:"))).toEqual(["host:host-b"]); +}); + +test("rejects malformed, accessor, Proxy, and secret-bearing evidence snapshots", async () => { + const variants: Array<() => unknown> = [ + () => ({ ...ownerReceipt("stop_and_detach"), secret: "prompt" }), + () => { + const value = ownerReceipt("stop_and_detach") as Record; + Object.defineProperty(value, "digest", { get: () => digest(), enumerable: true }); + return value; + }, + () => new Proxy(ownerReceipt("stop_and_detach"), {}), + () => ({ ...ownerReceipt("stop_and_detach"), digest: "raw-secret" }), + ]; + for (const make of variants) { + const store = new MemoryStore(); + const { owners } = fixture(); + owners.stopLiveTurnAndDetachRoute = async () => make() as never; + await expect(new AgentDeletionCoordinator(store, owners, () => 1).deleteSession(identity)) + .rejects.toBeInstanceOf(AgentDeletionReceiptError); + expect(JSON.stringify(store.record)).not.toContain("prompt"); + expect(JSON.stringify(store.record)).not.toContain("raw-secret"); + } +}); + +test("authoritative all-pin verification must bind full Host/generation/run-generation set", async () => { + const hosts: AgentDeletionHostTarget[] = [ + { hostId: "host-a", ledgerState: "active", generationId: "gen", runGeneration: 1 }, + { hostId: "host-a", ledgerState: "draining", generationId: "gen", runGeneration: 2 }, + ]; + const store = new MemoryStore(); + const { owners } = fixture({ hosts }); + owners.verifyHostDeletionAndReleasePins = async ({ pins, hostReceipts, ...id }) => ({ + schemaVersion: 1, + ...id, + phase: "verify_and_release_pins", + digest: digest("f"), + pins: pins.slice(0, 1), + hostReceipts, + }); + await expect(new AgentDeletionCoordinator(store, owners, () => 1).deleteSession(identity)) + .rejects.toThrow("full owner set"); + expect(store.record?.phase).toBe("verify_and_release_pins"); +}); + +test("all-settles gateway owners and recovers readiness without repeating prior phases", async () => { + const store = new MemoryStore(); + const first = fixture({ fail: "registries" }); + const coordinator = new AgentDeletionCoordinator(store, first.owners, () => 1); + await expect(coordinator.deleteSession(identity)).rejects.toThrow(); + expect(first.calls).toContain("ledger"); + expect(coordinator.readiness(store.record)).toEqual({ + ready: false, retryable: true, failedPhase: "settle_gateway", + }); + const recovery = fixture(); + await new AgentDeletionCoordinator(store, recovery.owners, () => 2).deleteSession(identity); + expect(recovery.calls).toEqual(["registries", "ledger", "release", "transcript", "kernel"]); +}); + +test("recovers a current-schema pre-kernel checkpoint and applies retention policy", async () => { + const store = new MemoryStore(); + const phases = AGENT_DELETION_PHASES.slice(0, -2) as readonly ActivePhase[]; + store.record = { + schemaVersion: AGENT_DELETION_SCHEMA_VERSION, + revision: phases.length, + ...identity, + createdAtMs: 1, + updatedAtMs: 2, + phase: "finish_kernel", + receipts: phases.map((phase) => ({ + phase, + completedAtMs: 2, + evidenceDigests: phase === "settle_gateway" ? [digest(), digest("b")] + : phase === "delete_hosts" ? [] : [digest()], + })), + enumeration: { pins: [], hosts: [] }, + hostReceipts: [], + }; + const { calls, owners } = fixture(); + const now = 20 * 24 * 60 * 60 * 1_000; + const coordinator = new AgentDeletionCoordinator(store, owners, () => now); + await coordinator.deleteSession(identity); + expect(calls).toEqual(["kernel"]); + await coordinator.cleanupExpiredSuccesses(); + expect(store.cleanupBefore).toBe(now - AGENT_DELETION_RECEIPT_RETENTION_MS); +}); + +test("rejects an exact-schema record for a different delete request", async () => { + const store = new MemoryStore(); + const { owners } = fixture(); + await new AgentDeletionCoordinator(store, owners, () => 1).deleteSession(identity); + await expect(new AgentDeletionCoordinator(store, owners, () => 2).deleteSession({ + ...identity, deleteRequestId: "different", + })).rejects.toBeInstanceOf(AgentDeletionReceiptError); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/deletion-coordinator.ts b/packages/core/opensession-server/src/server/agent-operation/deletion-coordinator.ts new file mode 100644 index 0000000000..c0315eb035 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/deletion-coordinator.ts @@ -0,0 +1,767 @@ +import { types as utilTypes } from "node:util"; + +/** Production-unwired, durable cross-owner Agent session deletion coordinator. */ + +export const AGENT_DELETION_SCHEMA_VERSION = 1 as const; +export const AGENT_DELETION_RECEIPT_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000; +export const AGENT_DELETION_PHASES = [ + "stop_and_detach", + "enumerate_pins", + "delete_hosts", + "settle_gateway", + "verify_and_release_pins", + "delete_transcript", + "finish_kernel", + "completed", +] as const; +export type AgentDeletionPhase = (typeof AGENT_DELETION_PHASES)[number]; +type ActivePhase = Exclude; +export type AgentDeletionDigest = `sha256:${string}`; + +export interface AgentDeletionIdentity { + readonly sessionId: string; + readonly deleteRequestId: string; +} + +export interface AgentDeletionFence { + readonly generationId: string; + readonly runGeneration: number; +} + +export type AgentDeletionHostLedgerState = "active" | "draining" | "blocked"; + +export interface AgentDeletionHostTarget extends AgentDeletionFence { + readonly hostId: string; + readonly ledgerState: AgentDeletionHostLedgerState; +} + +export interface AgentDeletionHostReceipt extends AgentDeletionHostTarget { + readonly schemaVersion: typeof AGENT_DELETION_SCHEMA_VERSION; + readonly sessionId: string; + readonly deleteRequestId: string; + readonly disposition: "tombstoned" | "already_tombstoned"; + readonly tombstoneDigest: AgentDeletionDigest; +} + +export interface AgentDeletionEnumeration { + readonly pins: readonly AgentDeletionFence[]; + readonly hosts: readonly AgentDeletionHostTarget[]; +} + +export interface AgentDeletionEnumerationReceipt extends AgentDeletionIdentity, + AgentDeletionEnumeration { + readonly schemaVersion: typeof AGENT_DELETION_SCHEMA_VERSION; + readonly digest: AgentDeletionDigest; +} + +export interface AgentDeletionOwnerReceipt

+ extends AgentDeletionIdentity { + readonly schemaVersion: typeof AGENT_DELETION_SCHEMA_VERSION; + readonly phase: P; + readonly digest: AgentDeletionDigest; +} + +/** Authoritative owner output. Its exact pin and Host receipt sets are verified. */ +export interface AgentDeletionPinVerificationReceipt extends AgentDeletionIdentity { + readonly schemaVersion: typeof AGENT_DELETION_SCHEMA_VERSION; + readonly phase: "verify_and_release_pins"; + readonly digest: AgentDeletionDigest; + readonly pins: readonly AgentDeletionFence[]; + readonly hostReceipts: readonly AgentDeletionHostReceipt[]; +} + +/** Durable phase evidence contains only bounded canonical digests. */ +export interface AgentDeletionPhaseReceipt { + readonly phase: ActivePhase; + readonly completedAtMs: number; + readonly evidenceDigests: readonly AgentDeletionDigest[]; +} + +export interface AgentDeletionRecord extends AgentDeletionIdentity { + readonly schemaVersion: typeof AGENT_DELETION_SCHEMA_VERSION; + readonly revision: number; + readonly createdAtMs: number; + readonly updatedAtMs: number; + readonly phase: AgentDeletionPhase; + readonly receipts: readonly AgentDeletionPhaseReceipt[]; + readonly enumeration?: AgentDeletionEnumeration; + readonly hostReceipts?: readonly AgentDeletionHostReceipt[]; + readonly completedAtMs?: number; + readonly lastFailure?: Readonly<{ + phase: ActivePhase; + failedAtMs: number; + retryable: true; + code: "owner_unavailable" | "invalid_receipt"; + }>; +} + +export type AgentDeletionCasResult = Readonly<{ + status: "committed" | "conflict"; + record: unknown; +}>; + +/** Every returned snapshot is treated as untrusted and decoded exactly. */ +export interface AgentDeletionDurableStore { + claim(input: Readonly): Promise; + load(identity: Readonly): Promise; + compareAndSwap(expectedRevision: number, next: Readonly): Promise; + cleanupCompleted(completedBeforeMs: number): Promise; +} + +export interface AgentDeletionOwners { + stopLiveTurnAndDetachRoute( + input: AgentDeletionIdentity, + ): Promise>; + enumeratePinsAndHosts(input: AgentDeletionIdentity): Promise; + deleteFromHost( + input: AgentDeletionIdentity & Readonly<{ target: AgentDeletionHostTarget }>, + ): Promise; + settleGatewayPrivateRegistries( + input: AgentDeletionIdentity, + ): Promise>; + settleOperationLedger( + input: AgentDeletionIdentity, + ): Promise>; + verifyHostDeletionAndReleasePins( + input: AgentDeletionIdentity & Readonly<{ + pins: readonly AgentDeletionFence[]; + hostReceipts: readonly AgentDeletionHostReceipt[]; + }>, + ): Promise; + deleteTranscriptAndResetWake( + input: AgentDeletionIdentity, + ): Promise>; + tombstoneKernelAndFinishArtifacts( + input: AgentDeletionIdentity, + ): Promise>; +} + +export interface AgentDeletionResult extends AgentDeletionIdentity { + readonly ok: true; + readonly completedAtMs: number; +} + +export interface AgentDeletionReadiness { + readonly ready: boolean; + readonly retryable: boolean; + readonly failedPhase?: ActivePhase; +} + +export class AgentDeletionReceiptError extends Error { + readonly retryable = true; + constructor(message: string) { + super(message); + this.name = "AgentDeletionReceiptError"; + } +} + +export class AgentDeletionCoordinator { + constructor( + private readonly store: AgentDeletionDurableStore, + private readonly owners: AgentDeletionOwners, + private readonly now: () => number = Date.now, + ) {} + + async deleteSession(identity: AgentDeletionIdentity): Promise { + validateIdentity(identity); + let record = decodeRecord(await this.store.claim({ + ...identity, + schemaVersion: AGENT_DELETION_SCHEMA_VERSION, + createdAtMs: this.now(), + }), identity); + + while (record.phase !== "completed") { + try { + record = await this.runPhase(record); + } catch (error) { + await this.checkpointFailure(record, error); + throw error; + } + } + return success(record); + } + + readiness(snapshot: unknown): AgentDeletionReadiness { + const record = decodeRecord(snapshot); + return record.lastFailure && record.phase !== "completed" + ? { ready: false, retryable: true, failedPhase: record.lastFailure.phase } + : { ready: true, retryable: false }; + } + + cleanupExpiredSuccesses(): Promise { + return this.store.cleanupCompleted(this.now() - AGENT_DELETION_RECEIPT_RETENTION_MS); + } + + private async runPhase(record: AgentDeletionRecord): Promise { + const identity = identityOf(record); + switch (record.phase) { + case "stop_and_detach": { + const receipt = decodeOwnerReceipt( + await this.owners.stopLiveTurnAndDetachRoute(identity), identity, record.phase, + ); + return this.advance(record, "stop_and_detach", [receipt.digest], "enumerate_pins"); + } + case "enumerate_pins": { + const receipt = decodeEnumerationReceipt( + await this.owners.enumeratePinsAndHosts(identity), identity, + ); + return this.advance( + withChanges(record, { enumeration: { pins: receipt.pins, hosts: receipt.hosts } }), + "enumerate_pins", [receipt.digest], "delete_hosts", + ); + } + case "delete_hosts": + return this.deleteHosts(record); + case "settle_gateway": { + const settled = await Promise.allSettled([ + this.owners.settleGatewayPrivateRegistries(identity), + this.owners.settleOperationLedger(identity), + ]); + if (settled[0].status === "rejected" || settled[1].status === "rejected") + throw new Error("gateway deletion owners did not all settle"); + const receipts = settled.map((item) => decodeOwnerReceipt( + (item as PromiseFulfilledResult).value, identity, "settle_gateway", + )); + return this.advance(record, "settle_gateway", receipts.map(({ digest }) => digest), + "verify_and_release_pins"); + } + case "verify_and_release_pins": { + const enumeration = requireEnumeration(record); + const hostReceipts = requireCompleteHostReceipts(record); + const receipt = decodeVerificationReceipt( + await this.owners.verifyHostDeletionAndReleasePins({ + ...identity, pins: enumeration.pins, hostReceipts, + }), identity, enumeration.pins, hostReceipts, + ); + return this.advance(record, "verify_and_release_pins", [receipt.digest], + "delete_transcript"); + } + case "delete_transcript": { + const receipt = decodeOwnerReceipt( + await this.owners.deleteTranscriptAndResetWake(identity), identity, record.phase, + ); + return this.advance(record, "delete_transcript", [receipt.digest], "finish_kernel"); + } + case "finish_kernel": { + const receipt = decodeOwnerReceipt( + await this.owners.tombstoneKernelAndFinishArtifacts(identity), identity, record.phase, + ); + return this.advance(record, "finish_kernel", [receipt.digest], "completed"); + } + } + throw new AgentDeletionReceiptError("invalid active deletion phase"); + } + + private async deleteHosts(record: AgentDeletionRecord): Promise { + const identity = identityOf(record); + const targets = requireEnumeration(record).hosts; + const existing = canonicalHostReceipts(identity, targets, record.hostReceipts ?? [], false); + const existingKeys = new Set(existing.map(hostKey)); + const unresolved = targets.filter((target) => !existingKeys.has(hostKey(target))); + const settled = await Promise.allSettled(unresolved.map((target) => + this.owners.deleteFromHost({ ...identity, target }))); + const accepted = [...existing]; + let firstError: unknown; + for (let index = 0; index < settled.length; index++) { + const result = settled[index]!; + if (result.status === "rejected") { + firstError ??= result.reason; + continue; + } + try { + accepted.push(decodeHostReceipt(result.value, identity, unresolved[index]!)); + } catch (error) { + firstError ??= error; + } + } + const canonical = canonicalHostReceipts(identity, targets, accepted, false); + if (firstError || canonical.length !== targets.length) { + const persisted = await this.checkpointSamePhase( + withChanges(record, { hostReceipts: canonical }), + ); + // A concurrent coordinator may already have completed this phase. + if (persisted.phase !== "delete_hosts") return persisted; + const failure = firstError ?? new Error("Host deletion owners did not all settle"); + await this.checkpointFailure(persisted, failure); + throw failure; + } + return this.advance( + withChanges(record, { hostReceipts: canonical }), + "delete_hosts", canonical.map(({ tombstoneDigest }) => tombstoneDigest), "settle_gateway", + ); + } + + private async advance( + record: AgentDeletionRecord, + phase: ActivePhase, + evidenceDigests: readonly AgentDeletionDigest[], + nextPhase: AgentDeletionPhase, + ): Promise { + if (record.phase !== phase) + throw new AgentDeletionReceiptError(`deletion phase changed before ${phase} checkpoint`); + const completedAtMs = this.now(); + const next = withChanges(record, { + phase: nextPhase, + updatedAtMs: completedAtMs, + revision: record.revision + 1, + receipts: [...record.receipts, { + phase, completedAtMs, evidenceDigests: Object.freeze([...evidenceDigests]), + }], + clearFailure: true, + ...(nextPhase === "completed" ? { completedAtMs } : {}), + }); + return this.casOrReload(record, next); + } + + private async checkpointSamePhase(record: AgentDeletionRecord): Promise { + const next = withChanges(record, { + revision: record.revision + 1, + updatedAtMs: this.now(), + clearFailure: true, + }); + return this.casOrReload(record, next); + } + + private async checkpointFailure(record: AgentDeletionRecord, error: unknown): Promise { + if (record.phase === "completed") return; + const code = error instanceof AgentDeletionReceiptError + ? "invalid_receipt" as const : "owner_unavailable" as const; + const failedAtMs = this.now(); + const next = withChanges(record, { + revision: record.revision + 1, + updatedAtMs: failedAtMs, + lastFailure: { phase: record.phase, failedAtMs, retryable: true, code }, + }); + try { + await this.casOrReload(record, next); + } catch { + // Never replace the owner/storage error, and never blind-write a failure. + } + } + + private async casOrReload( + expected: AgentDeletionRecord, + next: AgentDeletionRecord, + ): Promise { + const result = decodeCasResult( + await this.store.compareAndSwap(expected.revision, next), identityOf(expected), + ); + if (result.status === "committed") { + if (result.record.revision !== next.revision) + throw new AgentDeletionReceiptError("committed deletion revision mismatch"); + return result.record; + } + if (result.record.revision <= expected.revision) + throw new AgentDeletionReceiptError("deletion CAS conflict returned a stale revision"); + // Reload after conflict rather than trusting a potentially transient conflict snapshot. + const loaded = decodeRecord(await this.store.load(identityOf(expected)), identityOf(expected)); + if (loaded.revision < result.record.revision) + throw new AgentDeletionReceiptError("deletion reload regressed the durable revision"); + return loaded; + } +} + +type RecordChanges = Partial> & { clearFailure?: boolean }; + +function withChanges(record: AgentDeletionRecord, changes: RecordChanges): AgentDeletionRecord { + const result: Record = { + schemaVersion: record.schemaVersion, + revision: changes.revision ?? record.revision, + sessionId: record.sessionId, + deleteRequestId: record.deleteRequestId, + createdAtMs: record.createdAtMs, + updatedAtMs: changes.updatedAtMs ?? record.updatedAtMs, + phase: changes.phase ?? record.phase, + receipts: changes.receipts ?? record.receipts, + }; + const enumeration = changes.enumeration ?? record.enumeration; + const hostReceipts = changes.hostReceipts ?? record.hostReceipts; + const completedAtMs = changes.completedAtMs ?? record.completedAtMs; + const lastFailure = changes.clearFailure ? undefined : changes.lastFailure ?? record.lastFailure; + if (enumeration !== undefined) result.enumeration = enumeration; + if (hostReceipts !== undefined) result.hostReceipts = hostReceipts; + if (completedAtMs !== undefined) result.completedAtMs = completedAtMs; + if (lastFailure !== undefined) result.lastFailure = lastFailure; + return decodeRecord(result, record); +} + +function decodeCasResult(value: unknown, identity: AgentDeletionIdentity): { + status: "committed" | "conflict"; record: AgentDeletionRecord; +} { + const data = exactObject(value, ["status", "record"], "deletion CAS result"); + if (data.status !== "committed" && data.status !== "conflict") + throw new AgentDeletionReceiptError("invalid deletion CAS status"); + return { status: data.status, record: decodeRecord(data.record, identity) }; +} + +function decodeRecord(value: unknown, identity?: AgentDeletionIdentity): AgentDeletionRecord { + const data = exactObject(value, [ + "schemaVersion", "revision", "sessionId", "deleteRequestId", "createdAtMs", "updatedAtMs", + "phase", "receipts", "enumeration?", "hostReceipts?", "completedAtMs?", "lastFailure?", + ], "deletion record"); + if (data.schemaVersion !== AGENT_DELETION_SCHEMA_VERSION) + throw new AgentDeletionReceiptError("unsupported deletion record schema"); + const recordIdentity = decodeIdentity(data); + if (identity) assertIdentity(recordIdentity, identity); + const revision = safeNonnegativeInteger(data.revision, "deletion revision"); + const createdAtMs = safeNonnegativeInteger(data.createdAtMs, "deletion created time"); + const updatedAtMs = safeNonnegativeInteger(data.updatedAtMs, "deletion updated time"); + if (!isPhase(data.phase)) throw new AgentDeletionReceiptError("invalid deletion phase"); + const receiptValues = exactArray(data.receipts, 7, "deletion phase receipts"); + const receipts = receiptValues.map(decodePhaseReceipt); + const expectedReceiptPhases = AGENT_DELETION_PHASES.slice(0, phaseIndex(data.phase)); + if (receipts.length !== expectedReceiptPhases.length || receipts.some( + (receipt, index) => receipt.phase !== expectedReceiptPhases[index])) + throw new AgentDeletionReceiptError("deletion receipt phases are not an exact prefix"); + const enumeration = data.enumeration === undefined ? undefined : decodeEnumeration(data.enumeration); + const hostReceipts = data.hostReceipts === undefined ? undefined + : decodeHostReceiptArray(data.hostReceipts, recordIdentity); + if (phaseIndex(data.phase) >= phaseIndex("delete_hosts") && !enumeration) + throw new AgentDeletionReceiptError("durable deletion enumeration is missing"); + if (hostReceipts && !enumeration) + throw new AgentDeletionReceiptError("Host receipts have no deletion enumeration"); + const completedAtMs = data.completedAtMs === undefined ? undefined + : safeNonnegativeInteger(data.completedAtMs, "deletion completion time"); + if ((data.phase === "completed") !== (completedAtMs !== undefined)) + throw new AgentDeletionReceiptError("invalid deletion completion checkpoint"); + let lastFailure: AgentDeletionRecord["lastFailure"]; + if (data.lastFailure !== undefined) { + const failure = exactObject(data.lastFailure, + ["phase", "failedAtMs", "retryable", "code"], "deletion failure"); + if (!isActivePhase(failure.phase) || failure.retryable !== true || + (failure.code !== "owner_unavailable" && failure.code !== "invalid_receipt")) + throw new AgentDeletionReceiptError("invalid deletion failure"); + lastFailure = { + phase: failure.phase, + failedAtMs: safeNonnegativeInteger(failure.failedAtMs, "deletion failure time"), + retryable: true, + code: failure.code, + }; + } + const result: AgentDeletionRecord = { + schemaVersion: AGENT_DELETION_SCHEMA_VERSION, revision, ...recordIdentity, + createdAtMs, updatedAtMs, phase: data.phase, receipts: Object.freeze(receipts), + ...(enumeration ? { enumeration } : {}), + ...(hostReceipts ? { hostReceipts } : {}), + ...(completedAtMs !== undefined ? { completedAtMs } : {}), + ...(lastFailure ? { lastFailure } : {}), + }; + if (enumeration && hostReceipts) + canonicalHostReceipts(recordIdentity, enumeration.hosts, hostReceipts, false); + for (const receipt of receipts) { + const expectedDigests = receipt.phase === "settle_gateway" ? 2 + : receipt.phase === "delete_hosts" ? enumeration?.hosts.length ?? -1 + : 1; + if (receipt.evidenceDigests.length !== expectedDigests) + throw new AgentDeletionReceiptError("deletion phase has the wrong evidence digest count"); + } + return Object.freeze(result); +} + +function decodePhaseReceipt(value: unknown): AgentDeletionPhaseReceipt { + const data = exactObject(value, ["phase", "completedAtMs", "evidenceDigests"], + "deletion phase receipt"); + if (!isActivePhase(data.phase)) + throw new AgentDeletionReceiptError("invalid deletion phase receipt"); + const evidenceDigests = exactArray(data.evidenceDigests, 1_024, "deletion evidence digests"); + return Object.freeze({ + phase: data.phase, + completedAtMs: safeNonnegativeInteger(data.completedAtMs, "phase receipt time"), + evidenceDigests: Object.freeze(evidenceDigests.map(decodeDigest)), + }); +} + +function decodeOwnerReceipt

( + value: unknown, identity: AgentDeletionIdentity, phase: P, +): AgentDeletionOwnerReceipt

{ + const data = exactObject(value, + ["schemaVersion", "sessionId", "deleteRequestId", "phase", "digest"], "owner receipt"); + if (data.schemaVersion !== AGENT_DELETION_SCHEMA_VERSION || data.phase !== phase) + throw new AgentDeletionReceiptError("invalid deletion owner receipt"); + const receiptIdentity = decodeIdentity(data); + assertIdentity(receiptIdentity, identity); + return Object.freeze({ + schemaVersion: AGENT_DELETION_SCHEMA_VERSION, ...receiptIdentity, phase, + digest: decodeDigest(data.digest), + }); +} + +function decodeEnumerationReceipt( + value: unknown, identity: AgentDeletionIdentity, +): AgentDeletionEnumerationReceipt { + const data = exactObject(value, [ + "schemaVersion", "sessionId", "deleteRequestId", "digest", "pins", "hosts", + ], "enumeration receipt"); + if (data.schemaVersion !== AGENT_DELETION_SCHEMA_VERSION) + throw new AgentDeletionReceiptError("invalid deletion enumeration receipt"); + const receiptIdentity = decodeIdentity(data); + assertIdentity(receiptIdentity, identity); + const enumeration = decodeEnumeration({ pins: data.pins, hosts: data.hosts }); + return Object.freeze({ + schemaVersion: AGENT_DELETION_SCHEMA_VERSION, ...receiptIdentity, + digest: decodeDigest(data.digest), ...enumeration, + }); +} + +function decodeEnumeration(value: unknown): AgentDeletionEnumeration { + const data = exactObject(value, ["pins", "hosts"], "deletion enumeration"); + const pinValues = exactArray(data.pins, 1_024, "deletion pins"); + const hostValues = exactArray(data.hosts, 1_024, "deletion Hosts"); + const pins = uniqueSorted(pinValues.map(decodeFence), fenceKey); + const hosts = uniqueSorted(hostValues.map((item) => decodeHostTarget(item)), hostKey); + return Object.freeze({ pins: Object.freeze(pins), hosts: Object.freeze(hosts) }); +} + +function decodeHostReceipt( + value: unknown, identity: AgentDeletionIdentity, target?: AgentDeletionHostTarget, +): AgentDeletionHostReceipt { + const data = exactObject(value, [ + "schemaVersion", "sessionId", "deleteRequestId", "hostId", "ledgerState", + "generationId", "runGeneration", "disposition", "tombstoneDigest", + ], "Host deletion receipt"); + if (data.schemaVersion !== AGENT_DELETION_SCHEMA_VERSION || + (data.disposition !== "tombstoned" && data.disposition !== "already_tombstoned")) + throw new AgentDeletionReceiptError("invalid Host deletion receipt"); + const receiptIdentity = decodeIdentity(data); + assertIdentity(receiptIdentity, identity); + const receipt: AgentDeletionHostReceipt = Object.freeze({ + schemaVersion: AGENT_DELETION_SCHEMA_VERSION, ...receiptIdentity, + ...decodeHostTarget(data, true), disposition: data.disposition, + tombstoneDigest: decodeDigest(data.tombstoneDigest), + }); + if (target && hostKey(receipt) !== hostKey(target)) + throw new AgentDeletionReceiptError("stale Host deletion receipt"); + return receipt; +} + +function decodeHostReceiptArray( + value: unknown, identity: AgentDeletionIdentity, +): readonly AgentDeletionHostReceipt[] { + const values = exactArray(value, 1_024, "durable Host receipts"); + return Object.freeze(values.map((item) => decodeHostReceipt(item, identity))); +} + +function decodeVerificationReceipt( + value: unknown, + identity: AgentDeletionIdentity, + expectedPins: readonly AgentDeletionFence[], + expectedHosts: readonly AgentDeletionHostReceipt[], +): AgentDeletionPinVerificationReceipt { + const data = exactObject(value, [ + "schemaVersion", "sessionId", "deleteRequestId", "phase", "digest", "pins", "hostReceipts", + ], "pin verification receipt"); + if (data.schemaVersion !== AGENT_DELETION_SCHEMA_VERSION || + data.phase !== "verify_and_release_pins") + throw new AgentDeletionReceiptError("invalid pin verification receipt"); + const receiptIdentity = decodeIdentity(data); + assertIdentity(receiptIdentity, identity); + const pinValues = exactArray(data.pins, 1_024, "verified pin set"); + const pins = uniqueSorted(pinValues.map(decodeFence), fenceKey); + const hosts = decodeHostReceiptArray(data.hostReceipts, identity); + if (!sameKeys(pins, expectedPins, fenceKey) || !sameHostReceipts(hosts, expectedHosts)) + throw new AgentDeletionReceiptError("pin verification did not bind the full owner set"); + return Object.freeze({ + schemaVersion: AGENT_DELETION_SCHEMA_VERSION, ...receiptIdentity, + phase: "verify_and_release_pins", digest: decodeDigest(data.digest), + pins: Object.freeze(pins), hostReceipts: hosts, + }); +} + +function canonicalHostReceipts( + identity: AgentDeletionIdentity, + targets: readonly AgentDeletionHostTarget[], + receipts: readonly AgentDeletionHostReceipt[], + requireComplete: boolean, +): readonly AgentDeletionHostReceipt[] { + const targetKeys = new Set(targets.map(hostKey)); + const byTarget = new Map(); + for (const raw of receipts) { + const receipt = decodeHostReceipt(raw, identity); + const key = hostKey(receipt); + if (!targetKeys.has(key) || byTarget.has(key)) + throw new AgentDeletionReceiptError("unexpected or duplicate Host deletion receipt"); + byTarget.set(key, receipt); + } + if (requireComplete && byTarget.size !== targets.length) + throw new AgentDeletionReceiptError("incomplete Host deletion receipts"); + return Object.freeze(targets.flatMap((target) => { + const receipt = byTarget.get(hostKey(target)); + return receipt ? [receipt] : []; + })); +} + +function requireEnumeration(record: AgentDeletionRecord): AgentDeletionEnumeration { + if (!record.enumeration) + throw new AgentDeletionReceiptError("durable deletion enumeration is missing"); + return record.enumeration; +} + +function requireCompleteHostReceipts(record: AgentDeletionRecord): readonly AgentDeletionHostReceipt[] { + return canonicalHostReceipts(record, requireEnumeration(record).hosts, + record.hostReceipts ?? [], true); +} + +function decodeFence(value: unknown): AgentDeletionFence { + const data = exactObject(value, ["generationId", "runGeneration"], "generation fence"); + return Object.freeze({ + generationId: boundedString(data.generationId, "generation id"), + runGeneration: safeNonnegativeInteger(data.runGeneration, "run generation"), + }); +} + +function decodeHostTarget(value: unknown, embedded = false): AgentDeletionHostTarget { + const data = exactObject(value, + ["hostId", "ledgerState", "generationId", "runGeneration"], "Host target", embedded); + if (data.ledgerState !== "active" && data.ledgerState !== "draining" && + data.ledgerState !== "blocked") + throw new AgentDeletionReceiptError("invalid Host ledger state"); + return Object.freeze({ + hostId: boundedString(data.hostId, "Host id"), ledgerState: data.ledgerState, + generationId: boundedString(data.generationId, "generation id"), + runGeneration: safeNonnegativeInteger(data.runGeneration, "run generation"), + }); +} + +function exactObject( + value: unknown, + keySpec: readonly string[], + label: string, + allowAdditional = false, +): Record { + if (typeof value !== "object" || value === null || utilTypes.isProxy(value) || + Object.getPrototypeOf(value) !== Object.prototype) + throw new AgentDeletionReceiptError(`${label} must be a plain non-Proxy snapshot`); + const descriptors = Object.getOwnPropertyDescriptors(value); + const required = new Set(keySpec.filter((key) => !key.endsWith("?"))); + const allowed = new Set(keySpec.map((key) => key.replace(/\?$/, ""))); + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== "string" || (!allowAdditional && !allowed.has(key))) + throw new AgentDeletionReceiptError(`${label} has an unexpected key`); + const descriptor = descriptors[key]!; + if (!("value" in descriptor)) + throw new AgentDeletionReceiptError(`${label} contains an accessor`); + } + for (const key of required) { + if (!Object.hasOwn(descriptors, key)) + throw new AgentDeletionReceiptError(`${label} is missing ${key}`); + } + const result: Record = {}; + for (const [key, descriptor] of Object.entries(descriptors)) result[key] = descriptor.value; + return result; +} + +function exactArray(value: unknown, maxLength: number, label: string): unknown[] { + if (!Array.isArray(value) || utilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype) + throw new AgentDeletionReceiptError(`${label} must be a non-Proxy array snapshot`); + const descriptors = Object.getOwnPropertyDescriptors(value) as unknown as PropertyDescriptorMap; + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + const lengthValue = lengthDescriptor && "value" in lengthDescriptor + ? lengthDescriptor.value : undefined; + if (typeof lengthValue !== "number" || !Number.isSafeInteger(lengthValue) || + lengthValue < 0 || lengthValue > maxLength) + throw new AgentDeletionReceiptError(`${label} has an invalid length`); + const length = lengthValue; + const result: unknown[] = []; + for (const key of Reflect.ownKeys(descriptors)) { + if (key === "length") continue; + if (typeof key !== "string" || !/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= length) + throw new AgentDeletionReceiptError(`${label} has an unexpected key`); + if (!("value" in descriptors[key]!)) + throw new AgentDeletionReceiptError(`${label} contains an accessor`); + } + for (let index = 0; index < length; index++) { + const descriptor = descriptors[String(index)]; + if (!descriptor || !("value" in descriptor)) + throw new AgentDeletionReceiptError(`${label} contains a hole or accessor`); + result.push(descriptor.value); + } + return result; +} + +function decodeIdentity(value: Record): AgentDeletionIdentity { + return Object.freeze({ + sessionId: boundedString(value.sessionId, "session id"), + deleteRequestId: boundedString(value.deleteRequestId, "delete request id"), + }); +} + +function validateIdentity(identity: AgentDeletionIdentity): void { + // Exact owner-bound input is copied before crossing any owner boundary. + decodeIdentity(exactObject(identity, ["sessionId", "deleteRequestId"], "deletion identity")); +} + +function assertIdentity(actual: AgentDeletionIdentity, expected: AgentDeletionIdentity): void { + if (actual.sessionId !== expected.sessionId || actual.deleteRequestId !== expected.deleteRequestId) + throw new AgentDeletionReceiptError("durable deletion identity mismatch"); +} + +function identityOf(record: AgentDeletionIdentity): AgentDeletionIdentity { + return Object.freeze({ sessionId: record.sessionId, deleteRequestId: record.deleteRequestId }); +} + +function decodeDigest(value: unknown): AgentDeletionDigest { + if (typeof value !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value)) + throw new AgentDeletionReceiptError("invalid deletion evidence digest"); + return value as AgentDeletionDigest; +} + +function boundedString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length < 1 || value.length > 256 || /[\u0000-\u001f]/.test(value)) + throw new AgentDeletionReceiptError(`invalid ${label}`); + return value; +} + +function safeNonnegativeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) + throw new AgentDeletionReceiptError(`invalid ${label}`); + return value as number; +} + +function phaseIndex(phase: AgentDeletionPhase): number { + return AGENT_DELETION_PHASES.indexOf(phase); +} +function isPhase(value: unknown): value is AgentDeletionPhase { + return typeof value === "string" && (AGENT_DELETION_PHASES as readonly string[]).includes(value); +} +function isActivePhase(value: unknown): value is ActivePhase { + return isPhase(value) && value !== "completed"; +} +function fenceKey(value: AgentDeletionFence): string { + return `${value.generationId}\u0000${value.runGeneration}`; +} +function hostKey(value: AgentDeletionHostTarget): string { + return `${value.hostId}\u0000${value.ledgerState}\u0000${fenceKey(value)}`; +} +function uniqueSorted(values: readonly T[], key: (value: T) => string): T[] { + const map = new Map(); + for (const value of values) { + const id = key(value); + if (map.has(id)) throw new AgentDeletionReceiptError("duplicate deletion owner fence"); + map.set(id, value); + } + return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value); +} +function sameKeys(left: readonly T[], right: readonly T[], key: (value: T) => string): boolean { + return left.length === right.length && left.every((value, index) => key(value) === key(right[index]!)); +} +function sameHostReceipts( + left: readonly AgentDeletionHostReceipt[], right: readonly AgentDeletionHostReceipt[], +): boolean { + return left.length === right.length && left.every((value, index) => { + const other = right[index]!; + return hostKey(value) === hostKey(other) && value.disposition === other.disposition && + value.tombstoneDigest === other.tombstoneDigest; + }); +} +function success(record: AgentDeletionRecord): AgentDeletionResult { + if (record.phase !== "completed" || record.completedAtMs === undefined) + throw new AgentDeletionReceiptError("deletion is not complete"); + return Object.freeze({ + ok: true, ...identityOf(record), completedAtMs: record.completedAtMs, + }); +} diff --git a/packages/core/opensession-server/src/server/agent-operation/gateway.test.ts b/packages/core/opensession-server/src/server/agent-operation/gateway.test.ts new file mode 100644 index 0000000000..e41907fce1 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/gateway.test.ts @@ -0,0 +1,592 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + hashAgentMcpArgumentsV1, + hashAgentMcpPayloadV1, + hashAgentModelPayloadV1, + hashAgentOperationDescriptorV1, + type AgentOperationRequestV1, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentHostSupervisionAuthorityV2 } from "@tellahq/opensession-protocol/agent-host"; +import { + AgentGatewayGrantRegistry, + encodeAgentGatewayPolicyHandle, +} from "./grants"; +import { + AgentGatewayAmbiguousExecutionError, + AgentOperationGateway, + type AgentGatewayFailpoint, + type AgentGatewayLiveEventSink, +} from "./gateway"; +import { SQLiteAgentOperationLedger } from "./sqlite-ledger"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); +}); +const d = (c: string) => `sha256:${c.repeat(64)}` as const; +const bytes = new TextEncoder().encode("payload"); +const authority: AgentHostSupervisionAuthorityV2 = { + version: 2, + fence: { + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, + }, + planHash: d("a"), + hostId: "host-000000000001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + supervisorEpoch: 1, + kernelServiceEpoch: "kernel-epoch-0001", + hostChallenge: "challenge-00000001", + audience: "opensession-agent-host", + purpose: "agent-host-supervision", + issuedAtMs: 1, + expiresAtMs: 1_000_000, + nonce: "nonce-000000000001", + keyId: "key-0000000000001", +}; +const envelope = { + version: 1, + algorithm: "Ed25519", + domain: "opensession.agent-host.supervision.v2", + authorityBytes: "AQ", + signature: Buffer.alloc(64).toString("base64url"), +} as const; + +async function fixture( + failAt?: AgentGatewayFailpoint, + beforeAdapterCompletes?: () => Promise, + lifecycle?: { + begin?: (recordState: string) => Promise; + execute?: (sink?: AgentGatewayLiveEventSink) => Promise; + onAppend?: () => void; + onSettle?: () => void; + }, +) { + const root = mkdtempSync(join(tmpdir(), "agent-gateway-")); + roots.push(root); + const ledger = new SQLiteAgentOperationLedger({ + dbPath: join(root, "ledger.sqlite"), + }); + let now = 10; + const grants = new AgentGatewayGrantRegistry({ + now: () => now, + entropy: () => "x".repeat(43), + }); + const descriptor = { + version: 1, + kind: "model", + stepId: "step-1", + transcript: { throughChangeSeq: 2, entryIds: ["entry-1"], digest: d("c") }, + modelPolicyHash: d("d"), + adapterRequestVersion: "v1", + } as const; + const descriptorDigest = await hashAgentOperationDescriptorV1(descriptor); + const payloadDigest = await hashAgentModelPayloadV1(bytes); + const grant = grants.issue({ + operationId: "operation-1", + kind: "model", + fence: authority.fence, + planHash: d("a"), + authorityHash: d("b"), + supervisorEpoch: 1, + hostId: authority.hostId, + hostGeneration: 1, + hostIncarnation: authority.hostIncarnation, + descriptorDigest, + payloadDigest, + transcriptAnchor: descriptor.transcript, + adapterId: "adapter-1", + adapterVersion: "1.0", + deadlineMs: 500, + authorityExpiresAtMs: 600, + policyHandle: encodeAgentGatewayPolicyHandle("policy00000000001"), + }); + const request: AgentOperationRequestV1 = { + version: 1, + operationId: "operation-1", + kind: "model", + fence: authority.fence, + supervisionEnvelope: envelope, + dispatchGrant: grant, + descriptor, + descriptorDigest, + }; + const actor = { + admits: 0, + terminals: 0, + async admit() { + this.admits++; + return { accepted: true }; + }, + async settle() { + lifecycle?.onSettle?.(); + this.terminals++; + }, + async indeterminate() { + this.terminals++; + }, + }; + let executions = 0; + let terminalAppends = 0; + let notices = 0; + let tripped = false; + const gateway = new AgentOperationGateway({ + ledger, + grants, + now: () => ++now, + verifySupervision: async () => ({ authority, authorityHash: d("b") }), + admission: actor, + adapterFor: () => ({ + id: "adapter-1", + version: "1.0", + async execute(_request, _signal, sink) { + executions++; + await lifecycle?.execute?.(sink); + await beforeAdapterCompletes?.(); + return { + outcome: { status: "succeeded", outputDigest: d("e") }, + transcript: { text: "ephemeral" }, + }; + }, + }), + decodePayload: (kind, payload) => { + if (kind !== "model" || payload !== "payload") return undefined; + return Object.freeze({ kind, value: payload, canonicalBytes: bytes }); + }, + appendTerminal: async () => { + lifecycle?.onAppend?.(); + terminalAppends++; + return terminal("append-terminal", d("e"), "ok"); + }, + beginLiveExecution: lifecycle?.begin + ? (record) => lifecycle.begin!(record.receipt.state) + : undefined, + appendIndeterminateNotice: async (record, appendId) => { + notices++; + return terminal( + appendId, + d("f"), + record.terminalReservation?.reason ?? "reconciliation_unsupported", + ).kernelTerminal; + }, + failpoint: async (point) => { + if (point === failAt && !tripped) { + tripped = true; + throw new Error(`fail:${point}`); + } + }, + }); + return { + gateway, + ledger, + request, + actor, + counts: () => ({ executions, terminalAppends, notices }), + }; +} +async function mcpFixture(options?: { + canonicalArgumentsBytes?: Uint8Array; + resolvedAnchor?: { + throughChangeSeq: number; + entryIds: string[]; + digest: ReturnType; + }; + decodedValue?: unknown; +}) { + const root = mkdtempSync(join(tmpdir(), "agent-gateway-mcp-")); + roots.push(root); + const ledger = new SQLiteAgentOperationLedger({ + dbPath: join(root, "ledger.sqlite"), + }); + let now = 10; + const grants = new AgentGatewayGrantRegistry({ + now: () => now, + entropy: () => "m".repeat(43), + }); + const argumentsBytes = new TextEncoder().encode('{"query":"safe"}'); + const payloadBytes = new TextEncoder().encode( + '{"arguments":{"query":"safe"}}', + ); + const transcriptAnchor = { + throughChangeSeq: 4, + entryIds: ["entry-tool-use"], + digest: d("7"), + } as const; + const descriptor = { + version: 1, + kind: "mcp", + toolUseEntryId: "entry-tool-use", + toolUseId: "tool-use-1", + server: "search-server", + tool: "search", + argumentsDigest: await hashAgentMcpArgumentsV1(argumentsBytes), + adapterRequestVersion: "v1", + } as const; + const descriptorDigest = await hashAgentOperationDescriptorV1(descriptor); + const payloadDigest = await hashAgentMcpPayloadV1(payloadBytes); + const grant = grants.issue({ + operationId: "operation-mcp-1", + kind: "mcp", + fence: authority.fence, + planHash: d("a"), + authorityHash: d("b"), + supervisorEpoch: 1, + hostId: authority.hostId, + hostGeneration: 1, + hostIncarnation: authority.hostIncarnation, + descriptorDigest, + payloadDigest, + transcriptAnchor, + toolUseEntryId: descriptor.toolUseEntryId, + adapterId: "mcp-adapter-1", + adapterVersion: "1.0", + deadlineMs: 500, + authorityExpiresAtMs: 600, + policyHandle: encodeAgentGatewayPolicyHandle("mcppolicy00000001"), + }); + const request: AgentOperationRequestV1 = { + version: 1, + operationId: "operation-mcp-1", + kind: "mcp", + fence: authority.fence, + supervisionEnvelope: envelope, + dispatchGrant: grant, + descriptor, + descriptorDigest, + }; + let admits = 0; + let executions = 0; + let resolverCalls = 0; + let adapterRequest: unknown; + const gateway = new AgentOperationGateway({ + ledger, + grants, + now: () => ++now, + verifySupervision: async () => ({ authority, authorityHash: d("b") }), + admission: { + async admit() { + admits++; + return { accepted: true }; + }, + async settle() {}, + async indeterminate() {}, + }, + adapterFor: () => ({ + id: "mcp-adapter-1", + version: "1.0", + async execute(value) { + executions++; + adapterRequest = value; + return { + outcome: { status: "succeeded", outputDigest: d("8") }, + transcript: { text: "ephemeral" }, + }; + }, + }), + decodePayload: (kind, payload) => + Object.freeze({ + kind: kind as "mcp", + value: + options && "decodedValue" in options ? options.decodedValue : payload, + canonicalBytes: payloadBytes, + canonicalArgumentsBytes: + options?.canonicalArgumentsBytes ?? argumentsBytes, + }), + resolveTranscriptAnchor: async (resolvedRequest, toolUseEntryId) => { + resolverCalls++; + expect(resolvedRequest).toEqual(request); + expect(toolUseEntryId).toBe(descriptor.toolUseEntryId); + await Promise.resolve(); + return options?.resolvedAnchor ?? transcriptAnchor; + }, + appendTerminal: async () => { + const completed = terminal("append-mcp-terminal", d("8"), "ok"); + return { + refs: completed.refs, + kernelTerminal: { + outputDigest: d("8"), + outcomeCode: "ok", + transcriptRefs: completed.refs, + }, + }; + }, + appendIndeterminateNotice: async () => { + throw new Error("unexpected recovery"); + }, + }); + return { + gateway, + ledger, + request, + counts: () => ({ admits, executions, resolverCalls }), + adapterRequest: () => + adapterRequest as + | { + identity: { + operationId: string; + descriptor: unknown; + toolUseEntryId?: string; + }; + payload: unknown; + } + | undefined, + }; +} + +function terminal( + appendId: string, + outputDigest: `sha256:${string}`, + outcomeCode: string, +) { + const refs = [ + { + appendId, + entryIds: [`entry-${appendId}`], + firstSeq: 3, + lastSeq: 3, + throughChangeSeq: 3, + requestDigest: d("1"), + }, + ]; + return { + refs, + kernelTerminal: { + outputDigest, + outcomeCode, + transcriptRefs: refs, + pendingToolUseEntryIds: [], + }, + }; +} + +const points: AgentGatewayFailpoint[] = [ + "after_admission", + "after_prepared", + "after_executing", + "after_transcript_append", + "after_ledger_settlement", + "after_schema_settlement", +]; +describe("Agent operation gateway durable choreography", () => { + for (const point of points) + test(`failpoint ${point} never repeats physical work`, async () => { + const f = await fixture(point); + await expect(f.gateway.dispatch(f.request, "payload")).rejects.toThrow( + `fail:${point}`, + ); + const active = await f.ledger.scanActive(); + if (active.some((record) => record.receipt.state === "executing")) + await f.gateway.recoverActive(); + else await f.gateway.dispatch(f.request, "payload"); + const records = await f.ledger.scanActive(); + expect(records).toHaveLength(0); + expect(f.counts().executions).toBeLessThanOrEqual(1); + if (["after_executing", "after_transcript_append"].includes(point)) { + expect(f.counts().executions).toBe(point === "after_executing" ? 0 : 1); + expect(f.counts().notices).toBe(1); + } + await f.ledger.close(); + }); + + test("orders acknowledged live events before transcript and terminal settlement", async () => { + const order: string[] = []; + const f = await fixture(undefined, undefined, { + begin: async (state) => { + order.push(`begin:${state}`); + return { + async publish() { + order.push("publish"); + }, + async close() { + order.push("close"); + return Object.freeze({ frames: 1 }); + }, + async fail() { + order.push("fail"); + }, + }; + }, + execute: async (sink) => { + await sink!.publish(Object.freeze({ token: "x" })); + order.push("execute"); + }, + onAppend: () => order.push("append"), + onSettle: () => order.push("settle"), + }); + await f.gateway.dispatch(f.request, "payload"); + expect(order).toEqual([ + "begin:executing", + "publish", + "execute", + "close", + "append", + "settle", + ]); + expect(f.counts()).toMatchObject({ terminalAppends: 1, executions: 1 }); + await f.ledger.close(); + }); + + test("typed ambiguity is immediately reserved, appended, and settled without retry", async () => { + const f = await fixture(undefined, undefined, { + execute: async () => { + throw new AgentGatewayAmbiguousExecutionError("timeout_ambiguous"); + }, + }); + const terminal = await f.gateway.dispatch(f.request, "payload"); + expect(terminal.receipt.state).toBe("indeterminate"); + expect(terminal.receipt.kernelTerminal?.outcomeCode).toBe( + "timeout_ambiguous", + ); + expect(f.counts()).toEqual({ + executions: 1, + terminalAppends: 0, + notices: 1, + }); + expect(f.actor.terminals).toBe(1); + await f.ledger.close(); + }); + + test("concurrent duplicate replay invokes the adapter exactly once", async () => { + const f = await fixture(); + const [a, b] = await Promise.all([ + f.gateway.dispatch(f.request, "payload"), + f.gateway.dispatch(f.request, "payload"), + ]); + expect(a.receipt.state).toBe("settled"); + expect(b.receipt.state).toBe("settled"); + expect(f.counts()).toMatchObject({ executions: 1, terminalAppends: 1 }); + await f.ledger.close(); + }); + + test("prepared recovery is inert and requires a fresh authorized dispatch", async () => { + const f = await fixture("after_prepared"); + await expect(f.gateway.dispatch(f.request, "payload")).rejects.toThrow(); + const recovered = await f.gateway.recoverActive(); + expect(recovered.prepared).toHaveLength(1); + expect(f.counts().executions).toBe(0); + await f.gateway.dispatch(f.request, "payload"); + expect(f.counts().executions).toBe(1); + await f.ledger.close(); + }); + + test("does not hold the actor while physical work is blocked", async () => { + let release!: () => void; + let started!: () => void; + const adapterStarted = new Promise((resolve) => (started = resolve)); + const adapterRelease = new Promise((resolve) => (release = resolve)); + const f = await fixture(undefined, async () => { + started(); + await adapterRelease; + }); + const dispatch = f.gateway.dispatch(f.request, "payload"); + await adapterStarted; + await expect(f.actor.admit()).resolves.toEqual({ accepted: true }); + release(); + await dispatch; + expect(f.counts().executions).toBe(1); + await f.ledger.close(); + }); + + test("forged request and stale grant fail before admission or physical work", async () => { + const f = await fixture(); + await expect( + f.gateway.dispatch({ ...f.request, descriptorDigest: d("9") }, "payload"), + ).rejects.toThrow(); + await expect( + f.gateway.dispatch( + { ...f.request, dispatchGrant: "osag_dispatch_v1." + "z".repeat(43) }, + "payload", + ), + ).rejects.toThrow(); + expect(f.actor.admits).toBe(0); + expect(f.counts().executions).toBe(0); + await f.ledger.close(); + }); + + test("MCP dispatch binds canonical arguments, exact identity, and server anchor", async () => { + const payload = { arguments: { query: "safe" } }; + const f = await mcpFixture({ decodedValue: payload }); + const settled = await f.gateway.dispatch(f.request, payload); + expect(settled.receipt.state).toBe("settled"); + expect(f.counts()).toEqual({ admits: 1, executions: 1, resolverCalls: 1 }); + const execution = f.adapterRequest(); + expect(execution?.identity).toMatchObject({ + operationId: "operation-mcp-1", + descriptor: f.request.descriptor, + toolUseEntryId: "entry-tool-use", + }); + expect(execution?.payload).toEqual(payload); + expect(execution?.payload).not.toBe(payload); + expect(Object.isFrozen(execution?.payload)).toBe(true); + expect( + Object.isFrozen((execution?.payload as { arguments: object }).arguments), + ).toBe(true); + await f.ledger.close(); + }); + + test("MCP argument digest mismatch fails before admission or physical work", async () => { + const f = await mcpFixture({ + canonicalArgumentsBytes: new TextEncoder().encode('{"query":"changed"}'), + }); + await expect( + f.gateway.dispatch(f.request, { arguments: {} }), + ).rejects.toThrow("arguments digest mismatch"); + expect(f.counts()).toEqual({ admits: 0, executions: 0, resolverCalls: 0 }); + await f.ledger.close(); + }); + + test("Proxy and getter payloads fail before admission or physical work", async () => { + let getterCalls = 0; + const getterPayload = Object.defineProperty({}, "arguments", { + enumerable: true, + get() { + getterCalls++; + return { query: `mutation-${getterCalls}` }; + }, + }); + const proxyPayload = new Proxy( + { arguments: { query: "safe" } }, + { + get(target, property, receiver) { + if (property === "arguments") getterCalls++; + return Reflect.get(target, property, receiver); + }, + }, + ); + for (const payload of [getterPayload, proxyPayload]) { + const f = await mcpFixture({ decodedValue: payload }); + await expect(f.gateway.dispatch(f.request, payload)).rejects.toThrow( + "invalid decoded payload", + ); + expect(f.counts()).toEqual({ + admits: 0, + executions: 0, + resolverCalls: 0, + }); + await f.ledger.close(); + } + expect(getterCalls).toBe(0); + }); + + test("wrong MCP tool-use anchor fails before admission or physical work", async () => { + const f = await mcpFixture({ + resolvedAnchor: { + throughChangeSeq: 4, + entryIds: ["entry-other-tool-use"], + digest: d("6"), + }, + }); + await expect( + f.gateway.dispatch(f.request, { arguments: {} }), + ).rejects.toThrow(); + expect(f.counts()).toEqual({ admits: 0, executions: 0, resolverCalls: 1 }); + await f.ledger.close(); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/gateway.ts b/packages/core/opensession-server/src/server/agent-operation/gateway.ts new file mode 100644 index 0000000000..7cbb1c894d --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/gateway.ts @@ -0,0 +1,686 @@ +import { + decodeAgentOperationRequestV1, + hashAgentMcpArgumentsV1, + hashAgentMcpPayloadV1, + hashAgentModelPayloadV1, + hashAgentOperationDescriptorV1, + hashAgentOperationReceiptV1, + type AgentOperationDigest, + type AgentOperationKernelTerminalV1, + type AgentOperationOutcomeV1, + type AgentOperationRequestV1, + type AgentTranscriptReceiptRefV1, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentHostSupervisionAuthorityV2 } from "@tellahq/opensession-protocol/agent-host"; +import type { SignedAgentHostSupervisionEnvelopeV1 } from "@tellahq/opensession-protocol/agent-host-supervision"; +import { + type AgentGatewayGrantExpectation, + type AgentGatewayGrantRegistry, +} from "./grants"; +import { + AgentOperationConflictError, + type AgentOperationIdentity, + type AgentOperationIndeterminateReason, + type AgentOperationLedger, + type AgentOperationRecord, + type AgentOperationSettlement, + type AgentOperationTerminalReservation, + type ExecutingOperationReconciler, + reconcileExecutingOperation, +} from "./ledger"; + +export type AgentGatewayFailpoint = + | "after_admission" + | "after_prepared" + | "after_executing" + | "after_transcript_append" + | "after_ledger_settlement" + | "after_schema_settlement"; + +export interface VerifiedAgentSupervision { + readonly authority: AgentHostSupervisionAuthorityV2; + readonly authorityHash: AgentOperationDigest; +} +export interface AgentGatewayAdmissionFacade { + admit(identity: AgentOperationIdentity): Promise<{ accepted: boolean }>; + settle( + identity: AgentOperationIdentity, + gatewayReceiptDigest: AgentOperationDigest, + terminal: Readonly, + ): Promise; + indeterminate( + identity: AgentOperationIdentity, + gatewayReceiptDigest: AgentOperationDigest, + terminal: Readonly, + ): Promise; +} +export interface AgentGatewayAdapterResult { + readonly outcome: AgentOperationOutcomeV1; + /** Ephemeral material consumed by the transcript appender, never persisted. */ + readonly transcript: unknown; + readonly providerRequestRef?: string; + readonly providerResponseRef?: string; +} +export interface AgentGatewayLiveEventSink { + publish(event: Readonly): Promise; + /** Flushes the bounded stream and returns opaque transport evidence. */ + close(): Promise; + fail(reason: unknown): Promise; +} +export interface AgentGatewayAdapter { + readonly id: string; + readonly version: string; + execute( + request: Readonly<{ + identity: Readonly; + payload: unknown; + }>, + signal: AbortSignal, + sink?: AgentGatewayLiveEventSink, + ): Promise; +} +export type AgentGatewayDecodedPayload = + | Readonly<{ + kind: "model"; + value: unknown; + canonicalBytes: Uint8Array; + /** Trusted decoders may retain an already-validated immutable private capability. */ + retainValueIdentity?: true; + }> + | Readonly<{ + kind: "mcp"; + value: unknown; + canonicalBytes: Uint8Array; + canonicalArgumentsBytes: Uint8Array; + }>; +export interface AgentGatewayTranscriptTerminal { + readonly refs: readonly AgentTranscriptReceiptRefV1[]; + readonly kernelTerminal: Readonly; +} +export interface AgentOperationGatewayOptions { + readonly ledger: AgentOperationLedger; + readonly grants: AgentGatewayGrantRegistry; + readonly verifySupervision: ( + envelope: SignedAgentHostSupervisionEnvelopeV1, + request: AgentOperationRequestV1, + ) => Promise; + readonly admission: AgentGatewayAdmissionFacade; + readonly adapterFor: ( + request: AgentOperationRequestV1, + ) => AgentGatewayAdapter | undefined; + /** Strictly decodes one raw snapshot into an immutable adapter value and canonical bytes. */ + readonly decodePayload: ( + kind: "model" | "mcp", + payload: unknown, + request: Readonly, + ) => AgentGatewayDecodedPayload | undefined; + /** Required for MCP, whose descriptor intentionally does not carry a transcript anchor. */ + readonly resolveTranscriptAnchor?: ( + request: AgentOperationRequestV1, + toolUseEntryId: string, + ) => + | AgentOperationIdentity["transcriptAnchor"] + | undefined + | Promise; + readonly appendTerminal: ( + identity: AgentOperationIdentity, + result: AgentGatewayAdapterResult, + ) => Promise; + readonly appendIndeterminateNotice: ( + record: AgentOperationRecord, + appendId: string, + ) => Promise; + /** Optional and production-unwired. Resolves only after Host transport acknowledgement. */ + readonly beginLiveExecution?: ( + record: AgentOperationRecord, + ) => Promise; + readonly reconcilerFor?: ( + record: AgentOperationRecord, + ) => ExecutingOperationReconciler | undefined; + readonly now?: () => number; + readonly failpoint?: ( + point: AgentGatewayFailpoint, + record: AgentOperationRecord, + ) => void | Promise; +} + +/** Import-inert coordinator. It owns no sockets, timers, listeners, or credentials. */ +export class AgentOperationGateway { + readonly #options: AgentOperationGatewayOptions; + readonly #mailboxes = new Map>(); + constructor(options: AgentOperationGatewayOptions) { + this.#options = options; + } + + dispatch( + rawRequest: unknown, + payload: unknown, + signal = new AbortController().signal, + ) { + const request = decodeAgentOperationRequestV1(rawRequest); + if (!request) + return Promise.reject(new AgentGatewayRequestError("invalid request")); + return this.#serialize(keyForRequest(request), () => + this.#dispatch(request, payload, signal), + ); + } + + async recoverActive(): Promise<{ + prepared: AgentOperationRecord[]; + recovered: AgentOperationRecord[]; + }> { + const active = await this.#options.ledger.scanActive(); + const prepared: AgentOperationRecord[] = []; + const recovered: AgentOperationRecord[] = []; + await Promise.all( + active.map((record) => + this.#serialize(keyFor(record), async () => { + if (record.receipt.state === "prepared") { + // Payloads and bearer grants are intentionally not durable. A fresh dispatch + // must reauthorize this record before it can become executing. + prepared.push(record); + return; + } + if (record.receipt.state !== "executing") return; + const terminal = await reconcileExecutingOperation( + this.#options.ledger, + record, + this.#options.reconcilerFor?.(record), + async (authenticated, reservation) => + this.#options.appendIndeterminateNotice( + authenticated, + indeterminateAppendId( + authenticated.operationId, + reservation.reservationId, + ), + ), + this.#now(), + ); + if ( + terminal.receipt.state !== "settled" && + terminal.receipt.state !== "indeterminate" + ) + throw new AgentOperationConflictError( + "recovery did not reach terminal state", + ); + await this.#settleActor(terminal); + recovered.push(terminal); + }), + ), + ); + return { prepared, recovered }; + } + + async #dispatch( + request: AgentOperationRequestV1, + payload: unknown, + signal: AbortSignal, + ) { + const verified = await this.#options.verifySupervision( + request.supervisionEnvelope, + request, + ); + if (!verified) + throw new AgentGatewayAuthorizationError("invalid supervision"); + const descriptorDigest = await hashAgentOperationDescriptorV1( + request.descriptor, + ); + if (descriptorDigest !== request.descriptorDigest) + throw new AgentGatewayAuthorizationError("descriptor digest mismatch"); + let decoded: AgentGatewayDecodedPayload | undefined; + try { + decoded = this.#options.decodePayload(request.kind, payload, request); + } catch { + throw new AgentGatewayRequestError("invalid payload"); + } + if ( + !decoded || + decoded.kind !== request.kind || + !(decoded.canonicalBytes instanceof Uint8Array) || + (decoded.kind === "mcp" && + !(decoded.canonicalArgumentsBytes instanceof Uint8Array)) + ) + throw new AgentGatewayRequestError("invalid payload decoding"); + let adapterPayload: unknown; + try { + adapterPayload = + decoded.kind === "model" && decoded.retainValueIdentity + ? validateDecodedValue(decoded.value) + : snapshotDecodedValue(decoded.value); + } catch { + throw new AgentGatewayRequestError("invalid decoded payload"); + } + const payloadBytes = decoded.canonicalBytes.slice(); + const payloadDigest = + request.kind === "model" + ? await hashAgentModelPayloadV1(payloadBytes) + : await hashAgentMcpPayloadV1(payloadBytes); + if (request.kind === "mcp") { + if (decoded.kind !== "mcp" || request.descriptor.kind !== "mcp") + throw new AgentGatewayRequestError("invalid MCP payload decoding"); + const argumentsDigest = await hashAgentMcpArgumentsV1( + decoded.canonicalArgumentsBytes.slice(), + ); + if (argumentsDigest !== request.descriptor.argumentsDigest) + throw new AgentGatewayAuthorizationError("arguments digest mismatch"); + } + const authority = verified.authority; + if (!sameFence(request.fence, authority.fence)) + throw new AgentGatewayAuthorizationError("supervision fence mismatch"); + const adapter = this.#options.adapterFor(request); + if (!adapter) + throw new AgentGatewayAuthorizationError("adapter unavailable"); + const transcriptAnchor = + request.descriptor.kind === "model" + ? request.descriptor.transcript + : await this.#options.resolveTranscriptAnchor?.( + request, + request.descriptor.toolUseEntryId, + ); + if (!transcriptAnchor) + throw new AgentGatewayRequestError("missing transcript anchor"); + const identity = this.#provisionalIdentity( + request, + verified, + payloadDigest, + transcriptAnchor, + adapter.id, + adapter.version, + ); + const authorization = this.#options.grants.authorize( + request.dispatchGrant, + grantExpectation(identity), + ); + if (!authorization.authorized) + throw new AgentGatewayAuthorizationError(authorization.reason); + + const existing = await this.#options.ledger.getExact(identity); + if ( + existing?.receipt.state === "settled" || + existing?.receipt.state === "indeterminate" + ) { + await this.#settleActor(existing); + return existing; + } + const admitted = await this.#options.admission.admit(identity); + if (!admitted.accepted) throw new AgentGatewayAdmissionError(); + await this.#hit("after_admission", existing ?? synthetic(identity)); + const claim = await this.#options.ledger.claimPrepared( + identity, + this.#now(), + ); + await this.#hit("after_prepared", claim.record); + if (claim.record.receipt.state !== "prepared") { + if (claim.record.receipt.state === "executing") + throw new AgentGatewayInheritedExecutionError(); + await this.#settleActor(claim.record); + return claim.record; + } + const executing = await this.#options.ledger.markExecuting( + identity, + this.#now(), + ); + await this.#hit("after_executing", executing); + let sink: AgentGatewayLiveEventSink | undefined; + try { + if (this.#options.beginLiveExecution) { + let liveSink: AgentGatewayLiveEventSink; + try { + liveSink = await this.#options.beginLiveExecution(executing); + } catch { + throw new AgentGatewayAmbiguousExecutionError("disconnect_ambiguous"); + } + sink = guardedLiveEventSink(liveSink); + } + const result = await adapter.execute( + Object.freeze({ identity, payload: adapterPayload }), + signal, + sink, + ); + await sink?.close(); + const appended = await this.#options.appendTerminal(identity, result); + await this.#hit("after_transcript_append", executing); + const settlement: AgentOperationSettlement = { + completedAtMs: this.#now(), + outcome: result.outcome, + transcriptRefs: appended.refs, + kernelTerminal: appended.kernelTerminal, + ...(result.providerRequestRef === undefined + ? {} + : { providerRequestRef: result.providerRequestRef }), + ...(result.providerResponseRef === undefined + ? {} + : { providerResponseRef: result.providerResponseRef }), + }; + const settled = await this.#options.ledger.settle(identity, settlement); + await this.#hit("after_ledger_settlement", settled); + await this.#settleActor(settled); + await this.#hit("after_schema_settlement", settled); + return settled; + } catch (error) { + const ambiguity = + error instanceof AgentGatewayAmbiguousExecutionError + ? error + : undefined; + if (!ambiguity) throw error; + try { + await sink?.fail(ambiguity); + } catch { + // The durable terminal reservation, not best-effort stream cleanup, owns settlement. + } + return this.#settleAmbiguous(executing, ambiguity.reason); + } + } + + async #settleAmbiguous( + executing: AgentOperationRecord, + reason: AgentOperationIndeterminateReason, + ) { + const reservation = await this.#options.ledger.reserveIndeterminate( + executing, + reason, + this.#now(), + ); + const authenticated = await this.#options.ledger.getExact(executing); + if ( + !authenticated || + authenticated.receipt.state !== "executing" || + !authenticated.terminalReservation || + !sameReservation(authenticated.terminalReservation, reservation) + ) + throw new AgentOperationConflictError( + "agent operation terminal reservation mismatch", + ); + const terminal = await this.#options.appendIndeterminateNotice( + authenticated, + indeterminateAppendId( + authenticated.operationId, + authenticated.terminalReservation.reservationId, + ), + ); + const settled = await this.#options.ledger.markIndeterminate( + authenticated, + authenticated.terminalReservation, + this.#now(), + terminal, + ); + await this.#settleActor(settled); + return settled; + } + + #provisionalIdentity( + request: AgentOperationRequestV1, + verified: VerifiedAgentSupervision, + payloadDigest: AgentOperationDigest, + transcriptAnchor: AgentOperationIdentity["transcriptAnchor"], + adapterId: string, + adapterVersion: string, + ): AgentOperationIdentity { + const authority = verified.authority; + return { + operationId: request.operationId, + kind: request.kind, + fence: request.fence, + planHash: authority.planHash as AgentOperationDigest, + authorityHash: verified.authorityHash, + supervisorEpoch: authority.supervisorEpoch, + hostId: authority.hostId, + hostGeneration: authority.hostGeneration, + hostIncarnation: authority.hostIncarnation, + transcriptAnchor, + ...(request.descriptor.kind === "mcp" + ? { toolUseEntryId: request.descriptor.toolUseEntryId } + : {}), + descriptor: request.descriptor, + descriptorDigest: request.descriptorDigest, + payloadDigest, + adapterId, + adapterVersion, + }; + } + + async #settleActor(record: AgentOperationRecord) { + const terminal = record.receipt.kernelTerminal; + if (!terminal) + throw new AgentOperationConflictError("terminal actor evidence missing"); + const digest = await hashAgentOperationReceiptV1(record.receipt); + if (record.receipt.state === "settled") + await this.#options.admission.settle(record, digest, terminal); + else if (record.receipt.state === "indeterminate") + await this.#options.admission.indeterminate(record, digest, terminal); + } + #now() { + const now = (this.#options.now ?? Date.now)(); + if (!Number.isSafeInteger(now) || now < 0) + throw new TypeError("invalid gateway clock"); + return now; + } + async #hit(point: AgentGatewayFailpoint, record: AgentOperationRecord) { + await this.#options.failpoint?.(point, record); + } + #serialize(key: string, work: () => Promise): Promise { + const prior = this.#mailboxes.get(key) ?? Promise.resolve(); + const next = prior.catch(() => undefined).then(work); + this.#mailboxes.set(key, next); + void next + .finally(() => { + if (this.#mailboxes.get(key) === next) this.#mailboxes.delete(key); + }) + .catch(() => undefined); + return next; + } +} + +function validateDecodedValue(value: unknown): unknown { + immutableSnapshot(value); + // structuredClone rejects Proxy objects. Run it only after the descriptor walk, + // which rejects accessors without invoking them. + structuredClone(value); + return value; +} + +function snapshotDecodedValue(value: unknown): unknown { + validateDecodedValue(value); + return immutableSnapshot(value); +} + +function immutableSnapshot(value: unknown): unknown { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) + ) + return value; + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) + throw new TypeError("invalid decoded payload array"); + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if ( + keys.some((key) => typeof key !== "string") || + keys.length !== value.length + 1 + ) + throw new TypeError("invalid decoded payload array"); + const snapshot = Array.from({ length: value.length }, (_, index) => { + const descriptor = descriptors[String(index)]; + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) + throw new TypeError("invalid decoded payload array"); + return immutableSnapshot(descriptor.value); + }); + return Object.freeze(snapshot); + } + if (typeof value !== "object") + throw new TypeError("invalid decoded payload value"); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError("invalid decoded payload object"); + const descriptors = Object.getOwnPropertyDescriptors(value); + const snapshot: Record = Object.create(null); + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== "string") + throw new TypeError("invalid decoded payload object"); + const descriptor = descriptors[key]; + if ( + !descriptor || + !("value" in descriptor) || + !descriptor.enumerable || + descriptor.value === undefined || + /^(?:__proto__|prototype|constructor)$/.test(key) + ) + throw new TypeError("invalid decoded payload object"); + snapshot[key] = immutableSnapshot(descriptor.value); + } + return Object.freeze(snapshot); +} + +function grantExpectation( + identity: AgentOperationIdentity, +): AgentGatewayGrantExpectation { + return { + operationId: identity.operationId, + kind: identity.kind, + fence: identity.fence, + planHash: identity.planHash, + authorityHash: identity.authorityHash, + supervisorEpoch: identity.supervisorEpoch, + hostId: identity.hostId, + hostGeneration: identity.hostGeneration, + hostIncarnation: identity.hostIncarnation, + descriptorDigest: identity.descriptorDigest, + payloadDigest: identity.payloadDigest, + transcriptAnchor: identity.transcriptAnchor, + ...(identity.kind === "mcp" + ? { toolUseEntryId: identity.toolUseEntryId! } + : {}), + adapterId: identity.adapterId, + adapterVersion: identity.adapterVersion, + }; +} +function sameFence( + a: AgentOperationRequestV1["fence"], + b: AgentOperationRequestV1["fence"], +) { + return ( + a.sessionId === b.sessionId && + a.runId === b.runId && + a.turnId === b.turnId && + a.generation === b.generation + ); +} +function keyForRequest(request: AgentOperationRequestV1) { + return `${request.fence.sessionId}\0${request.operationId}`; +} +function keyFor(record: AgentOperationRecord) { + return `${record.fence.sessionId}\0${record.operationId}`; +} +function sameReservation( + a: Readonly, + b: Readonly, +) { + return ( + a.reservationId === b.reservationId && + a.reason === b.reason && + a.reservedAtMs === b.reservedAtMs + ); +} +function guardedLiveEventSink( + sink: AgentGatewayLiveEventSink, +): AgentGatewayLiveEventSink { + let closed = false; + let failed = false; + return Object.freeze({ + async publish(event: Readonly) { + if (closed) + throw new AgentGatewayAmbiguousExecutionError("disconnect_ambiguous"); + try { + await sink.publish(event); + } catch { + closed = true; + throw new AgentGatewayAmbiguousExecutionError("disconnect_ambiguous"); + } + }, + async close() { + if (closed) + throw new AgentGatewayAmbiguousExecutionError("disconnect_ambiguous"); + closed = true; + try { + return await sink.close(); + } catch { + throw new AgentGatewayAmbiguousExecutionError("disconnect_ambiguous"); + } + }, + async fail(reason: unknown) { + if (failed) return; + failed = true; + closed = true; + await sink.fail(reason); + }, + }); +} +function indeterminateAppendId(operationId: string, reservationId: string) { + return `agent-indeterminate:${operationId}:${reservationId}`; +} +function synthetic(identity: AgentOperationIdentity): AgentOperationRecord { + return { + ...identity, + receipt: { + version: 1, + operationId: identity.operationId, + kind: identity.kind, + fence: identity.fence, + planHash: identity.planHash, + authorityHash: identity.authorityHash, + descriptorDigest: identity.descriptorDigest, + payloadDigest: identity.payloadDigest, + actorIdentity: { + supervisorEpoch: identity.supervisorEpoch, + hostId: identity.hostId, + hostGeneration: identity.hostGeneration, + hostIncarnation: identity.hostIncarnation, + transcriptAnchor: identity.transcriptAnchor, + ...(identity.kind === "mcp" + ? { toolUseEntryId: identity.toolUseEntryId } + : {}), + }, + state: "prepared", + acceptedAtMs: 0, + providerRef: { + adapterId: identity.adapterId, + adapterVersion: identity.adapterVersion, + }, + }, + }; +} +export class AgentGatewayAmbiguousExecutionError extends Error { + readonly reason: AgentOperationIndeterminateReason; + constructor(reason: AgentOperationIndeterminateReason) { + super(`agent operation completion is ambiguous: ${reason}`); + this.name = "AgentGatewayAmbiguousExecutionError"; + this.reason = reason; + } +} +export class AgentGatewayRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "AgentGatewayRequestError"; + } +} +export class AgentGatewayAuthorizationError extends Error { + constructor(message: string) { + super(message); + this.name = "AgentGatewayAuthorizationError"; + } +} +export class AgentGatewayAdmissionError extends Error { + constructor() { + super("agent operation admission rejected"); + this.name = "AgentGatewayAdmissionError"; + } +} +export class AgentGatewayInheritedExecutionError extends Error { + constructor() { + super("inherited executing operation requires recovery"); + this.name = "AgentGatewayInheritedExecutionError"; + } +} diff --git a/packages/core/opensession-server/src/server/agent-operation/grants.test.ts b/packages/core/opensession-server/src/server/agent-operation/grants.test.ts new file mode 100644 index 0000000000..c6c7c9ab7d --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/grants.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, test } from "bun:test"; +import { encodeExecutorGrant } from "@tellahq/opensession-protocol/executor"; +import { + AgentGatewayGrantCapacityError, + AgentGatewayGrantClockError, + AgentGatewayGrantEntropyError, + AgentGatewayGrantPolicyError, + AgentGatewayGrantRegistry, + encodeAgentGatewayPolicyHandle, + type AgentGatewayGrantBinding, + type AgentGatewayGrantExpectation, +} from "./grants"; + +const digest = (char: string) => + `sha256:${char.repeat(64)}` as `sha256:${string}`; +const entropy = (char: string) => char.repeat(43); + +function binding( + over: Partial = {}, +): AgentGatewayGrantBinding { + return { + operationId: "operation-1", + kind: "model", + fence: { + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 3, + }, + planHash: digest("a"), + authorityHash: digest("b"), + supervisorEpoch: 7, + hostId: "host-1", + hostGeneration: 2, + hostIncarnation: "incarnation-1", + descriptorDigest: digest("c"), + payloadDigest: digest("d"), + transcriptAnchor: { + throughChangeSeq: 11, + entryIds: ["input-1"], + digest: digest("e"), + }, + adapterId: "model-adapter", + adapterVersion: "v1", + deadlineMs: 10_500, + authorityExpiresAtMs: 11_000, + policyHandle: encodeAgentGatewayPolicyHandle("policy_handle_0001"), + ...over, + }; +} + +function expectation( + value: AgentGatewayGrantBinding, +): AgentGatewayGrantExpectation { + const { + policyHandle: _policyHandle, + deadlineMs: _deadlineMs, + authorityExpiresAtMs: _authorityExpiresAtMs, + ...expected + } = value; + return expected; +} + +describe("Agent gateway dispatch grant registry", () => { + test("authorizes repeat dispatch and query use only for the exact binding", () => { + let now = 10_000; + const registry = new AgentGatewayGrantRegistry({ + now: () => now, + entropy: () => entropy("A"), + }); + const exact = binding(); + const grant = registry.issue(exact); + const first = registry.authorize(grant, expectation(exact)); + expect(first).toMatchObject({ authorized: true }); + if (!first.authorized) throw new Error("expected authorization"); + expect(first.evidence).toMatchObject({ + ...exact, + issuedAtMs: 10_000, + }); + expect(first.evidence.grantHash).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(JSON.stringify(first.evidence)).not.toContain(grant); + expect(registry.evidence()[0]).not.toHaveProperty("policyHandle"); + expect(JSON.stringify(registry.evidence())).not.toContain( + exact.policyHandle, + ); + expect(Object.isFrozen(first.evidence)).toBe(true); + expect(Object.isFrozen(first.evidence.fence)).toBe(true); + expect(Object.isFrozen(first.evidence.transcriptAnchor)).toBe(true); + expect(Object.isFrozen(first.evidence.transcriptAnchor.entryIds)).toBe( + true, + ); + now = 10_499; + expect(registry.authorize(grant, expectation(exact))).toMatchObject({ + authorized: true, + }); + }); + + test("rejects every exact identity crossover without revoking the grant", () => { + const registry = new AgentGatewayGrantRegistry({ + now: () => 10_000, + entropy: () => entropy("B"), + }); + const exact = binding(); + const grant = registry.issue(exact); + const expected = expectation(exact); + const mismatches: AgentGatewayGrantExpectation[] = [ + { ...expected, operationId: "operation-2" }, + { ...expected, kind: "mcp", toolUseEntryId: "tool-use-1" }, + { ...expected, fence: { ...expected.fence, sessionId: "session-2" } }, + { ...expected, fence: { ...expected.fence, runId: "run-2" } }, + { ...expected, fence: { ...expected.fence, turnId: "turn-2" } }, + { ...expected, fence: { ...expected.fence, generation: 4 } }, + { ...expected, planHash: digest("f") }, + { ...expected, authorityHash: digest("f") }, + { ...expected, supervisorEpoch: 8 }, + { ...expected, hostId: "host-2" }, + { ...expected, hostGeneration: 3 }, + { ...expected, hostIncarnation: "incarnation-2" }, + { ...expected, descriptorDigest: digest("f") }, + { ...expected, payloadDigest: digest("f") }, + { + ...expected, + transcriptAnchor: { + ...expected.transcriptAnchor, + throughChangeSeq: 12, + }, + }, + { + ...expected, + transcriptAnchor: { + ...expected.transcriptAnchor, + entryIds: ["input-2"], + }, + }, + { ...expected, adapterId: "other-adapter" }, + { ...expected, adapterVersion: "v2" }, + ]; + for (const mismatch of mismatches) + expect(registry.authorize(grant, mismatch)).toEqual({ + authorized: false, + reason: "identity_mismatch", + }); + expect(registry.authorize(grant, expected)).toMatchObject({ + authorized: true, + }); + }); + + test("accepts the canonical generation-zero fence boundary", () => { + const registry = new AgentGatewayGrantRegistry({ + now: () => 10_000, + entropy: () => entropy("0"), + }); + const exact = binding({ fence: { ...binding().fence, generation: 0 } }); + const grant = registry.issue(exact); + expect(registry.authorize(grant, expectation(exact))).toMatchObject({ + authorized: true, + }); + }); + + test("binds MCP tool-use identity and rejects runtime-domain crossover", () => { + const registry = new AgentGatewayGrantRegistry({ + now: () => 10_000, + entropy: () => entropy("C"), + }); + const exact = binding({ kind: "mcp", toolUseEntryId: "tool-entry-1" }); + const grant = registry.issue(exact); + expect(registry.authorize(grant, expectation(exact))).toMatchObject({ + authorized: true, + }); + expect( + registry.authorize(grant, { + ...expectation(exact), + toolUseEntryId: "tool-entry-2", + }), + ).toEqual({ authorized: false, reason: "identity_mismatch" }); + expect( + registry.authorize(encodeExecutorGrant(entropy("X")), expectation(exact)), + ).toEqual({ authorized: false, reason: "invalid_grant" }); + }); + + test("expires at the exact deadline and prunes capacity without a timer", () => { + let now = 10_000; + let next = 0; + const registry = new AgentGatewayGrantRegistry({ + now: () => now, + capacity: 1, + entropy: () => entropy(next++ === 0 ? "D" : "E"), + }); + const firstBinding = binding(); + const first = registry.issue(firstBinding); + expect(() => + registry.issue(binding({ operationId: "operation-2" })), + ).toThrow(AgentGatewayGrantCapacityError); + now = firstBinding.deadlineMs; + expect(registry.authorize(first, expectation(firstBinding))).toEqual({ + authorized: false, + reason: "expired", + }); + expect(registry.size).toBe(0); + expect( + registry.issue(binding({ operationId: "operation-2", deadlineMs: 10_700 })), + ).toBeString(); + }); + + test("fails closed and clears grants when the clock moves backwards", () => { + let now = 10_000; + const registry = new AgentGatewayGrantRegistry({ + now: () => now, + entropy: () => entropy("Z"), + }); + registry.issue(binding()); + now--; + expect(() => registry.evidence()).toThrow(AgentGatewayGrantClockError); + now++; + expect(registry.size).toBe(0); + }); + + test("rejects invalid TTL and authority expiry before allocation", () => { + const registry = new AgentGatewayGrantRegistry({ + now: () => 10_000, + entropy: () => entropy("F"), + maxTtlMs: 500, + }); + for (const invalid of [ + binding({ deadlineMs: 10_000 }), + binding({ deadlineMs: 10_501 }), + binding({ deadlineMs: 10_500, authorityExpiresAtMs: 10_499 }), + ]) + expect(() => registry.issue(invalid)).toThrow( + AgentGatewayGrantPolicyError, + ); + expect(registry.size).toBe(0); + }); + + test("rejects accessors, Proxies, unknown keys and malformed anchors", () => { + const registry = new AgentGatewayGrantRegistry({ + now: () => 10_000, + entropy: () => entropy("G"), + }); + const exact = binding(); + const accessor = { ...exact }; + Object.defineProperty(accessor, "deadlineMs", { + enumerable: true, + get: () => 10_500, + }); + for (const invalid of [ + accessor, + new Proxy(exact, {}), + { ...exact, unknown: true }, + { + ...exact, + transcriptAnchor: { + ...exact.transcriptAnchor, + entryIds: ["input-1", "input-1"], + }, + }, + ]) + expect(() => registry.issue(invalid as AgentGatewayGrantBinding)).toThrow( + TypeError, + ); + expect(registry.size).toBe(0); + }); + + test("fails closed after bounded entropy collisions", () => { + const registry = new AgentGatewayGrantRegistry({ + now: () => 10_000, + capacity: 2, + entropy: () => entropy("H"), + }); + registry.issue(binding()); + expect(() => + registry.issue(binding({ operationId: "operation-2" })), + ).toThrow(AgentGatewayGrantEntropyError); + expect(registry.size).toBe(1); + }); + + test("revokes exact grants, sessions and Host incarnations", () => { + let next = 0; + const values = ["I", "J", "K", "L"]; + const registry = new AgentGatewayGrantRegistry({ + now: () => 10_000, + entropy: () => entropy(values[next++]!), + }); + const one = binding(); + const two = binding({ operationId: "operation-2" }); + const three = binding({ + operationId: "operation-3", + fence: { ...binding().fence, sessionId: "session-2" }, + }); + const four = binding({ + operationId: "operation-4", + fence: { ...binding().fence, sessionId: "session-3" }, + hostIncarnation: "incarnation-2", + }); + const oneGrant = registry.issue(one); + registry.issue(two); + registry.issue(three); + registry.issue(four); + expect(registry.revoke(oneGrant)).toBe(true); + expect(registry.revokeSession("session-1")).toBe(1); + expect(registry.revokeHost("host-1", "incarnation-1")).toBe(1); + expect(registry.revokeHost("host-1")).toBe(1); + expect(registry.size).toBe(0); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/grants.ts b/packages/core/opensession-server/src/server/agent-operation/grants.ts new file mode 100644 index 0000000000..3166c3ee95 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/grants.ts @@ -0,0 +1,572 @@ +import { randomBytes } from "node:crypto"; +import { + decodeAgentGatewayDispatchGrant, + encodeAgentGatewayDispatchGrant, + type AgentGatewayDispatchGrant, + type AgentOperationDigest, + type AgentOperationKind, + type AgentTranscriptAnchorV1, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentTurnFence } from "@tellahq/opensession-protocol/agent-host"; + +const GRANT_HASH_DOMAIN = "opensession.agent-gateway-grant-registry.v1\0"; +const POLICY_PREFIX = "osag_policy_v1."; +const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; +const VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/; +const DIGEST = /^sha256:[a-f0-9]{64}$/; +const POLICY_ID = /^[A-Za-z0-9_-]{16,256}$/; +const MAX_ANCHOR_IDS = 512; +const DEFAULT_CAPACITY = 100_000; +const DEFAULT_MAX_TTL_MS = 5 * 60_000; +const MAX_COLLISION_ATTEMPTS = 4; + +declare const policyHandleBrand: unique symbol; +export type AgentGatewayPolicyHandle = string & { + readonly [policyHandleBrand]: "AgentGatewayPolicyHandle"; +}; + +export function encodeAgentGatewayPolicyHandle( + id: string, +): AgentGatewayPolicyHandle { + if (!POLICY_ID.test(id)) throw new TypeError("invalid gateway policy handle"); + return `${POLICY_PREFIX}${id}` as AgentGatewayPolicyHandle; +} + +export function decodeAgentGatewayPolicyHandle( + value: unknown, +): AgentGatewayPolicyHandle | undefined { + if ( + typeof value !== "string" || + !value.startsWith(POLICY_PREFIX) || + !POLICY_ID.test(value.slice(POLICY_PREFIX.length)) + ) + return undefined; + return value as AgentGatewayPolicyHandle; +} + +export interface AgentGatewayGrantBinding { + readonly operationId: string; + readonly kind: AgentOperationKind; + readonly fence: Readonly; + readonly planHash: AgentOperationDigest; + readonly authorityHash: AgentOperationDigest; + readonly supervisorEpoch: number; + readonly hostId: string; + readonly hostGeneration: number; + readonly hostIncarnation: string; + readonly descriptorDigest: AgentOperationDigest; + readonly payloadDigest: AgentOperationDigest; + readonly transcriptAnchor: Readonly; + readonly toolUseEntryId?: string; + readonly adapterId: string; + readonly adapterVersion: string; + readonly deadlineMs: number; + readonly authorityExpiresAtMs: number; + readonly policyHandle: AgentGatewayPolicyHandle; +} + +export type AgentGatewayGrantExpectation = Omit< + AgentGatewayGrantBinding, + "policyHandle" | "deadlineMs" | "authorityExpiresAtMs" +>; + +export interface AgentGatewayGrantEvidence + extends AgentGatewayGrantBinding { + readonly grantHash: `sha256:${string}`; + readonly issuedAtMs: number; +} + +export type AgentGatewayGrantDiagnosticEvidence = Omit< + AgentGatewayGrantEvidence, + "policyHandle" +>; + +export type AgentGatewayGrantAuthorization = + | { readonly authorized: true; readonly evidence: AgentGatewayGrantEvidence } + | { + readonly authorized: false; + readonly reason: + | "invalid_grant" + | "expired" + | "identity_mismatch"; + }; + +export interface AgentGatewayGrantRegistryOptions { + readonly now?: () => number; + readonly entropy?: () => string; + readonly capacity?: number; + readonly maxTtlMs?: number; +} + +/** + * Bounded, import-inert, gateway-memory-only dispatch authority. Raw grants are + * returned once and never retained; the registry indexes only a domain-separated + * hash. There is deliberately no timer and no persistence path. + */ +export class AgentGatewayGrantRegistry { + readonly #now: () => number; + readonly #entropy: () => string; + readonly #capacity: number; + readonly #maxTtlMs: number; + readonly #records = new Map(); + #lastObservedNow = -1; + + constructor(options: AgentGatewayGrantRegistryOptions = {}) { + this.#now = options.now ?? Date.now; + this.#entropy = + options.entropy ?? (() => randomBytes(32).toString("base64url")); + this.#capacity = positive(options.capacity ?? DEFAULT_CAPACITY, "capacity"); + this.#maxTtlMs = positive( + options.maxTtlMs ?? DEFAULT_MAX_TTL_MS, + "maximum grant TTL", + ); + } + + issue(input: AgentGatewayGrantBinding): AgentGatewayDispatchGrant { + const now = this.#readNow(); + this.#pruneExpired(now); + const binding = decodeBinding(input); + if ( + binding.deadlineMs <= now || + binding.deadlineMs > binding.authorityExpiresAtMs || + binding.deadlineMs - now > this.#maxTtlMs + ) + throw new AgentGatewayGrantPolicyError("invalid grant deadline"); + if (this.#records.size >= this.#capacity) + throw new AgentGatewayGrantCapacityError(); + + for (let attempt = 0; attempt < MAX_COLLISION_ATTEMPTS; attempt++) { + const grant = encodeAgentGatewayDispatchGrant(this.#entropy()); + const grantHash = hashGrant(grant); + if (this.#records.has(grantHash)) continue; + this.#records.set( + grantHash, + freezeEvidence({ ...binding, grantHash, issuedAtMs: now }), + ); + return grant; + } + throw new AgentGatewayGrantEntropyError(); + } + + authorize( + grantInput: unknown, + expectationInput: AgentGatewayGrantExpectation, + ): AgentGatewayGrantAuthorization { + const grant = decodeAgentGatewayDispatchGrant(grantInput); + if (!grant) + return Object.freeze({ authorized: false, reason: "invalid_grant" }); + const expectation = decodeExpectation(expectationInput); + const grantHash = hashGrant(grant); + const evidence = this.#records.get(grantHash); + if (!evidence) + return Object.freeze({ authorized: false, reason: "invalid_grant" }); + const now = this.#readNow(); + if ( + now >= evidence.deadlineMs || + now >= evidence.authorityExpiresAtMs + ) { + this.#records.delete(grantHash); + return Object.freeze({ authorized: false, reason: "expired" }); + } + if (!sameExpectation(evidence, expectation)) + return Object.freeze({ + authorized: false, + reason: "identity_mismatch", + }); + return Object.freeze({ authorized: true, evidence }); + } + + revoke(grantInput: unknown): boolean { + const grant = decodeAgentGatewayDispatchGrant(grantInput); + return grant ? this.#records.delete(hashGrant(grant)) : false; + } + + revokeSession(sessionId: string): number { + const exactSessionId = exactId(sessionId, "session ID"); + return this.#deleteWhere( + (record) => record.fence.sessionId === exactSessionId, + ); + } + + revokeHost(hostId: string, hostIncarnation?: string): number { + const exactHostId = exactId(hostId, "Host ID"); + const exactIncarnation = + hostIncarnation === undefined + ? undefined + : exactId(hostIncarnation, "Host incarnation"); + return this.#deleteWhere( + (record) => + record.hostId === exactHostId && + (exactIncarnation === undefined || + record.hostIncarnation === exactIncarnation), + ); + } + + /** Bounded evidence for doctor/tests. It contains hashes and policy handles, + * never bearer grants or policy/config values. */ + evidence(): readonly AgentGatewayGrantDiagnosticEvidence[] { + this.#pruneExpired(this.#readNow()); + return Object.freeze( + [...this.#records.values()].map(({ policyHandle: _redacted, ...record }) => + Object.freeze(record), + ), + ); + } + + clear(): void { + this.#records.clear(); + } + + get size(): number { + this.#pruneExpired(this.#readNow()); + return this.#records.size; + } + + #readNow(): number { + const now = exactTime(this.#now(), "clock"); + if (now < this.#lastObservedNow) { + this.#records.clear(); + throw new AgentGatewayGrantClockError(); + } + this.#lastObservedNow = now; + return now; + } + + #pruneExpired(now: number): void { + this.#deleteWhere( + (record) => + now >= record.deadlineMs || now >= record.authorityExpiresAtMs, + ); + } + + #deleteWhere(predicate: (record: AgentGatewayGrantEvidence) => boolean) { + let deleted = 0; + for (const [hash, record] of this.#records) { + if (!predicate(record)) continue; + this.#records.delete(hash); + deleted++; + } + return deleted; + } +} + +export class AgentGatewayGrantCapacityError extends Error { + constructor() { + super("Agent gateway grant registry is full"); + this.name = "AgentGatewayGrantCapacityError"; + } +} + +export class AgentGatewayGrantPolicyError extends Error { + constructor(message: string) { + super(message); + this.name = "AgentGatewayGrantPolicyError"; + } +} + +export class AgentGatewayGrantClockError extends Error { + constructor() { + super("Agent gateway grant clock moved backwards"); + this.name = "AgentGatewayGrantClockError"; + } +} + +export class AgentGatewayGrantEntropyError extends Error { + constructor() { + super("Agent gateway grant entropy collided repeatedly"); + this.name = "AgentGatewayGrantEntropyError"; + } +} + +const BASE_KEYS = [ + "operationId", + "kind", + "fence", + "planHash", + "authorityHash", + "supervisorEpoch", + "hostId", + "hostGeneration", + "hostIncarnation", + "descriptorDigest", + "payloadDigest", + "transcriptAnchor", + "adapterId", + "adapterVersion", + "deadlineMs", + "authorityExpiresAtMs", + "policyHandle", +] as const; +const EXPECTATION_KEYS = BASE_KEYS.filter( + (key) => + key !== "policyHandle" && + key !== "deadlineMs" && + key !== "authorityExpiresAtMs", +); + +function decodeBinding(value: unknown): AgentGatewayGrantBinding { + const snapshot = snapshotJson(value, "grant binding"); + const record = exactRecord( + snapshot, + (snapshot as { kind?: unknown }).kind === "mcp" + ? [...BASE_KEYS, "toolUseEntryId"] + : BASE_KEYS, + "grant binding", + ); + return decodeCommon(record, true) as AgentGatewayGrantBinding; +} + +function decodeExpectation(value: unknown): AgentGatewayGrantExpectation { + const snapshot = snapshotJson(value, "grant expectation"); + const record = exactRecord( + snapshot, + (snapshot as { kind?: unknown }).kind === "mcp" + ? [...EXPECTATION_KEYS, "toolUseEntryId"] + : EXPECTATION_KEYS, + "grant expectation", + ); + return decodeCommon(record, false) as AgentGatewayGrantExpectation; +} + +function decodeCommon( + record: Record, + binding: boolean, +): AgentGatewayGrantBinding | AgentGatewayGrantExpectation { + const kind = record.kind; + if (kind !== "model" && kind !== "mcp") + throw new TypeError("invalid grant operation kind"); + const fenceRecord = exactRecord( + record.fence, + ["sessionId", "runId", "turnId", "generation"], + "grant fence", + ); + const fence = Object.freeze({ + sessionId: exactId(fenceRecord.sessionId, "session ID"), + runId: exactId(fenceRecord.runId, "run ID"), + turnId: exactId(fenceRecord.turnId, "turn ID"), + generation: nonnegative(fenceRecord.generation, "generation"), + }); + const anchorRecord = exactRecord( + record.transcriptAnchor, + ["throughChangeSeq", "entryIds", "digest"], + "transcript anchor", + ); + if ( + !Array.isArray(anchorRecord.entryIds) || + anchorRecord.entryIds.length > MAX_ANCHOR_IDS + ) + throw new TypeError("invalid transcript anchor entries"); + const entryIds = anchorRecord.entryIds.map((entryId) => + exactId(entryId, "transcript anchor entry ID"), + ); + if (new Set(entryIds).size !== entryIds.length) + throw new TypeError("duplicate transcript anchor entry ID"); + const transcriptAnchor = Object.freeze({ + throughChangeSeq: nonnegative( + anchorRecord.throughChangeSeq, + "transcript anchor cursor", + ), + entryIds: Object.freeze(entryIds), + digest: exactDigest(anchorRecord.digest, "transcript anchor digest"), + }); + const common = { + operationId: exactId(record.operationId, "operation ID"), + kind: kind as AgentOperationKind, + fence, + planHash: exactDigest(record.planHash, "plan hash"), + authorityHash: exactDigest(record.authorityHash, "authority hash"), + supervisorEpoch: positive(record.supervisorEpoch, "supervisor epoch"), + hostId: exactId(record.hostId, "Host ID"), + hostGeneration: positive(record.hostGeneration, "Host generation"), + hostIncarnation: exactId(record.hostIncarnation, "Host incarnation"), + descriptorDigest: exactDigest( + record.descriptorDigest, + "descriptor digest", + ), + payloadDigest: exactDigest(record.payloadDigest, "payload digest"), + transcriptAnchor, + ...(kind === "mcp" + ? { toolUseEntryId: exactId(record.toolUseEntryId, "tool-use entry ID") } + : {}), + adapterId: exactId(record.adapterId, "adapter ID"), + adapterVersion: exactVersion(record.adapterVersion, "adapter version"), + }; + if (!binding) return Object.freeze(common); + const policyHandle = decodeAgentGatewayPolicyHandle(record.policyHandle); + if (!policyHandle) throw new TypeError("invalid gateway policy handle"); + return Object.freeze({ + ...common, + deadlineMs: nonnegative(record.deadlineMs, "grant deadline"), + authorityExpiresAtMs: nonnegative( + record.authorityExpiresAtMs, + "authority expiry", + ), + policyHandle, + }); +} + +function sameExpectation( + evidence: AgentGatewayGrantEvidence, + expectation: AgentGatewayGrantExpectation, +): boolean { + return ( + evidence.operationId === expectation.operationId && + evidence.kind === expectation.kind && + evidence.fence.sessionId === expectation.fence.sessionId && + evidence.fence.runId === expectation.fence.runId && + evidence.fence.turnId === expectation.fence.turnId && + evidence.fence.generation === expectation.fence.generation && + evidence.planHash === expectation.planHash && + evidence.authorityHash === expectation.authorityHash && + evidence.supervisorEpoch === expectation.supervisorEpoch && + evidence.hostId === expectation.hostId && + evidence.hostGeneration === expectation.hostGeneration && + evidence.hostIncarnation === expectation.hostIncarnation && + evidence.descriptorDigest === expectation.descriptorDigest && + evidence.payloadDigest === expectation.payloadDigest && + evidence.transcriptAnchor.throughChangeSeq === + expectation.transcriptAnchor.throughChangeSeq && + evidence.transcriptAnchor.digest === expectation.transcriptAnchor.digest && + sameStrings( + evidence.transcriptAnchor.entryIds, + expectation.transcriptAnchor.entryIds, + ) && + evidence.toolUseEntryId === expectation.toolUseEntryId && + evidence.adapterId === expectation.adapterId && + evidence.adapterVersion === expectation.adapterVersion + ); +} + +function freezeEvidence( + evidence: AgentGatewayGrantEvidence, +): AgentGatewayGrantEvidence { + return Object.freeze({ ...evidence }); +} + +function hashGrant(grant: AgentGatewayDispatchGrant): `sha256:${string}` { + const digest = new Bun.CryptoHasher("sha256") + .update(GRANT_HASH_DOMAIN) + .update(grant) + .digest("hex"); + return `sha256:${digest}`; +} + +function sameStrings(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function exactRecord( + value: unknown, + keys: readonly string[], + name: string, +): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.keys(value).length !== keys.length || + Object.keys(value).some((key) => !keys.includes(key)) + ) + throw new TypeError(`invalid ${name}`); + return value as Record; +} + +function snapshotJson(value: unknown, name: string): unknown { + assertSafeJson(value, name); + try { + const snapshot = structuredClone(value); + assertSafeJson(snapshot, name); + return snapshot; + } catch { + throw new TypeError(`invalid ${name}`); + } +} + +function assertSafeJson( + value: unknown, + name: string, + seen = new Set(), + depth = 0, +): void { + if (depth > 12) throw new TypeError(`${name} is too deeply nested`); + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) + return; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError(`invalid ${name}`); + return; + } + if (!value || typeof value !== "object" || seen.has(value)) + throw new TypeError(`invalid ${name}`); + seen.add(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if (keys.some((key) => typeof key !== "string")) + throw new TypeError(`invalid ${name}`); + if (Array.isArray(value)) { + if ( + Object.getPrototypeOf(value) !== Array.prototype || + keys.length !== value.length + 1 + ) + throw new TypeError(`invalid ${name}`); + for (let index = 0; index < value.length; index++) { + const descriptor = descriptors[String(index)]; + if ( + !descriptor || + !("value" in descriptor) || + !descriptor.enumerable || + descriptor.value === undefined + ) + throw new TypeError(`invalid ${name}`); + assertSafeJson(descriptor.value, name, seen, depth + 1); + } + } else { + if (Object.getPrototypeOf(value) !== Object.prototype) + throw new TypeError(`invalid ${name}`); + for (const key of keys as string[]) { + const descriptor = descriptors[key]; + if ( + !descriptor || + !("value" in descriptor) || + !descriptor.enumerable || + descriptor.value === undefined + ) + throw new TypeError(`invalid ${name}`); + assertSafeJson(descriptor.value, name, seen, depth + 1); + } + } + seen.delete(value); +} + +function exactId(value: unknown, name: string): string { + if (typeof value !== "string" || !ID.test(value)) + throw new TypeError(`invalid ${name}`); + return value; +} +function exactVersion(value: unknown, name: string): string { + if (typeof value !== "string" || !VERSION.test(value)) + throw new TypeError(`invalid ${name}`); + return value; +} +function exactDigest(value: unknown, name: string): AgentOperationDigest { + if (typeof value !== "string" || !DIGEST.test(value)) + throw new TypeError(`invalid ${name}`); + return value as AgentOperationDigest; +} +function nonnegative(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) + throw new TypeError(`invalid ${name}`); + return value as number; +} +function positive(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) + throw new TypeError(`invalid ${name}`); + return value as number; +} +function exactTime(value: unknown, name: string): number { + return nonnegative(value, name); +} diff --git a/packages/core/opensession-server/src/server/agent-operation/kernel-facade.test.ts b/packages/core/opensession-server/src/server/agent-operation/kernel-facade.test.ts new file mode 100644 index 0000000000..ce1c259a6f --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/kernel-facade.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test"; +import type { AgentOperationKernelTerminalV1 } from "@tellahq/opensession-protocol/agent-operation"; +import { + SessionKernelActorError, + SessionKernelQuarantinedError, +} from "../session-kernel/actor-client"; +import type { + AgentOperationIdentity as ActorIdentity, + AgentOperationReceipt, + AgentOperationRequest, +} from "../session-kernel/agent-operation-protocol"; +import type { AgentOperationIdentity } from "./ledger"; +import { + AgentOperationKernelEvidenceError, + AgentOperationKernelFacade, + AgentOperationKernelQuarantinedError, + AgentOperationKernelRejectedError, + AgentOperationKernelTransportError, + createAgentOperationKernelFacades, +} from "./kernel-facade"; + +const d = (c: string) => `sha256:${c.repeat(64)}` as const; +const anchor = { + throughChangeSeq: 7, + digest: d("a"), + entryIds: ["input-1", "input-2"], +} as const; +const descriptor = { + version: 1, + kind: "model", + stepId: "step-1", + transcript: anchor, + modelPolicyHash: d("b"), + adapterRequestVersion: "v1", +} as const; + +function identity(kind: "model" | "mcp" = "model"): AgentOperationIdentity { + return { + operationId: `operation-${kind}`, + kind, + fence: { sessionId: "session-1", runId: "run-1", turnId: "turn-1", generation: 3 }, + planHash: d("c"), + authorityHash: d("d"), + supervisorEpoch: 4, + hostId: "host-1", + hostGeneration: 5, + hostIncarnation: "host-incarnation-1", + transcriptAnchor: anchor, + ...(kind === "mcp" ? { toolUseEntryId: "tool-use-1" } : {}), + descriptor: kind === "model" ? descriptor : { + version: 1, + kind: "mcp", + serverId: "server-1", + toolName: "tool-1", + toolUseEntryId: "tool-use-1", + argumentsDigest: d("e"), + adapterRequestVersion: "v1", + }, + descriptorDigest: d("f"), + payloadDigest: d("1"), + adapterId: "adapter-1", + adapterVersion: "2.3.4", + } as AgentOperationIdentity; +} + +function admitted(id: ActorIdentity): AgentOperationReceipt { + return { identity: id, sequence: 9, state: "admitted", admittedAtMs: 100 }; +} +const refs = [{ + appendId: "append-1", + entryIds: ["output-1", "pending-1", "pending-2"], + firstSeq: 10, + lastSeq: 12, + throughChangeSeq: 12, + requestDigest: d("2"), +}] as const; +function terminal(kind: "model" | "mcp" = "model"): AgentOperationKernelTerminalV1 { + const transcriptRefs = kind === "model" ? refs : [{ ...refs[0], entryIds: ["output-1"], lastSeq: 10 }]; + return { + outputDigest: d("3"), + outcomeCode: "ok", + transcriptRefs, + ...(kind === "model" ? { pendingToolUseEntryIds: ["pending-1", "pending-2"] } : {}), + }; +} +function terminalReceipt(request: Extract): AgentOperationReceipt { + return { + identity: request.identity, + sequence: 10, + state: request.op === "settle" ? "settled" : "indeterminate", + admittedAtMs: 100, + terminalAtMs: 200, + gatewayReceiptDigest: request.gatewayReceiptDigest, + outputDigest: request.outputDigest, + outcomeCode: request.outcomeCode, + transcriptReceipts: request.transcriptReceipts, + ...(request.identity.kind === "model" ? { pendingToolUseEntryIds: request.pendingToolUseEntryIds! } : {}), + }; +} + +type Handler = (request: AgentOperationRequest) => unknown | Promise; +function facade(handler: Handler) { + return new AgentOperationKernelFacade({ decideAgentOperationAsync: handler } as any); +} + +describe("AgentOperationKernelFacade", () => { + test("maps every model identity field and admits exact replay", async () => { + const seen: AgentOperationRequest[] = []; + const subject = facade((request) => { + seen.push(request); + return { accepted: true, replayed: seen.length > 1, receipt: admitted(request.identity) }; + }); + const expected = { + sessionId: "session-1", runId: "run-1", turnId: "turn-1", generation: 3, + operationId: "operation-model", kind: "model", descriptorDigest: d("f"), + payloadDigest: d("1"), adapterId: "adapter-1", adapterVersion: "2.3.4", + authorityHash: d("d"), supervisorEpoch: 4, planHash: d("c"), hostId: "host-1", + hostGeneration: 5, hostIncarnation: "host-incarnation-1", transcriptAnchor: anchor, + } as const; + await expect(subject.admit(identity())).resolves.toEqual({ accepted: true }); + await expect(subject.admit(identity())).resolves.toEqual({ accepted: true }); + expect(seen).toEqual([{ op: "admit", identity: expected }, { op: "admit", identity: expected }]); + }); + + test("maps MCP crossover only with its durable tool-use entry", async () => { + let seen!: AgentOperationRequest; + const subject = facade((request) => { + seen = request; + return { accepted: true, replayed: false, receipt: admitted(request.identity) }; + }); + await subject.admit(identity("mcp")); + expect(seen.identity).toMatchObject({ kind: "mcp", toolUseEntryId: "tool-use-1", transcriptAnchor: anchor }); + expect("toolUseEntryId" in (await captureAdmit(facade, identity("model"))).identity).toBe(false); + }); + + test("settles model and MCP with exact terminal refs and pending-tool order", async () => { + const requests: AgentOperationRequest[] = []; + const subject = facade((request) => { + requests.push(request); + if (request.op !== "settle" && request.op !== "indeterminate") throw new Error("unexpected"); + return { accepted: true, replayed: requests.length > 1, receipt: terminalReceipt(request) }; + }); + await subject.settle(identity(), d("4"), terminal()); + await subject.settle(identity(), d("4"), terminal()); + await subject.indeterminate(identity("mcp"), d("5"), terminal("mcp")); + expect(requests[0]).toMatchObject({ op: "settle", gatewayReceiptDigest: d("4"), transcriptReceipts: refs, pendingToolUseEntryIds: ["pending-1", "pending-2"] }); + expect(requests[1]).toEqual(requests[0]); + expect(requests[2]).toMatchObject({ op: "indeterminate", gatewayReceiptDigest: d("5") }); + expect("pendingToolUseEntryIds" in requests[2]!).toBe(false); + }); + + test("fails closed on actor rejection, quarantine, and contradictory accepted evidence", async () => { + await expect(facade(() => ({ accepted: false, reason: "authority_mismatch" })).admit(identity())) + .rejects.toMatchObject({ name: AgentOperationKernelRejectedError.name, reason: "authority_mismatch" }); + await expect(facade(() => { throw new SessionKernelQuarantinedError("session-1", "tamper"); }).admit(identity())) + .rejects.toBeInstanceOf(AgentOperationKernelQuarantinedError); + await expect(facade((request) => ({ accepted: true, replayed: false, receipt: admitted({ ...request.identity, hostId: "other-host" }) })).admit(identity())) + .rejects.toBeInstanceOf(AgentOperationKernelEvidenceError); + }); + + test("rejects terminal receipts missing exact terminal evidence", async () => { + const subject = facade((request) => { + if (request.op !== "settle") throw new Error("unexpected"); + return { accepted: true, replayed: false, receipt: { ...terminalReceipt(request), outcomeCode: "different" } }; + }); + await expect(subject.settle(identity(), d("4"), terminal())).rejects.toBeInstanceOf(AgentOperationKernelEvidenceError); + }); + + test("persists cancellation before returning requested, too_late, and replay", async () => { + const events: string[] = []; + let count = 0; + const subject = facade(async (request) => { + events.push("actor:start"); + await Promise.resolve(); + events.push("actor:durable"); + const disposition = request.op === "cancel" && request.cancelId === "late" ? "too_late" : "requested"; + count++; + return { accepted: true, replayed: count > 1, intent: { identity: request.identity, cancelId: (request as any).cancelId, reason: (request as any).reason, disposition, requestedAtMs: 100 } }; + }); + const requested = subject.request(identity(), "cancel-1", "user").then((value) => { events.push("caller:abort"); return value; }); + await expect(requested).resolves.toBe("requested"); + await expect(subject.request(identity(), "cancel-1", "user")).resolves.toBe("requested"); + await expect(subject.request(identity(), "late", "shutdown")).resolves.toBe("too_late"); + expect(events.slice(0, 3)).toEqual(["actor:start", "actor:durable", "caller:abort"]); + }); + + test("authorized query returns exact receipts, absence, and rejects crossover", async () => { + const ok = facade((request) => ({ accepted: true, replayed: true, receipt: admitted(request.identity) })); + await expect(ok.queryAuthorized(identity())).resolves.toMatchObject({ state: "admitted" }); + await expect(facade(() => ({ accepted: false, reason: "not_found" })).queryAuthorized(identity())).resolves.toBeUndefined(); + await expect(facade(() => ({ accepted: false, reason: "operation_barrier" })).queryAuthorized(identity())) + .rejects.toBeInstanceOf(AgentOperationKernelRejectedError); + }); + + test("never retries ambiguous actor mutations and remains responsive while physical work is blocked", async () => { + let calls = 0; + const ambiguous = facade(() => { calls++; throw new SessionKernelActorError("lost reply", true); }); + await expect(ambiguous.admit(identity())).rejects.toMatchObject({ + name: AgentOperationKernelTransportError.name, ambiguous: true, retryable: false, + }); + expect(calls).toBe(1); + + let release!: () => void; + const physical = new Promise((resolve) => { release = resolve; }); + const bundle = createAgentOperationKernelFacades({ + decideAgentOperationAsync: async (request: AgentOperationRequest) => ({ accepted: true, replayed: false, receipt: admitted(request.identity) }), + } as any); + void physical; // Simulates provider/transcript work owned outside this facade. + await expect(bundle.admission.admit(identity())).resolves.toEqual({ accepted: true }); + await expect(bundle.queryAuthorized(identity())).resolves.toMatchObject({ state: "admitted" }); + release(); + }); +}); + +async function captureAdmit(make: typeof facade, value: AgentOperationIdentity) { + let captured!: AgentOperationRequest; + await make((request) => { + captured = request; + return { accepted: true, replayed: false, receipt: admitted(request.identity) }; + }).admit(value); + return captured; +} diff --git a/packages/core/opensession-server/src/server/agent-operation/kernel-facade.ts b/packages/core/opensession-server/src/server/agent-operation/kernel-facade.ts new file mode 100644 index 0000000000..4e8523c18c --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/kernel-facade.ts @@ -0,0 +1,288 @@ +import type { AgentOperationKernelTerminalV1 } from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentHostCancelIntent } from "../agent-host-client"; +import { + SessionKernelActorError, + SessionKernelQuarantinedError, + type SessionKernelActorClient, +} from "../session-kernel/actor-client"; +import { + canonicalAgentOperationIdentity, + canonicalAgentOperationTerminal, + decodeAgentOperationCancellationIntent, + decodeAgentOperationIdentity, + decodeAgentOperationReceipt, + type AgentOperationCancellationResult, + type AgentOperationIdentity as ActorAgentOperationIdentity, + type AgentOperationReceipt as ActorAgentOperationReceipt, + type AgentOperationRequest as ActorAgentOperationRequest, + type AgentOperationResult, + type AgentOperationTerminal, +} from "../session-kernel/agent-operation-protocol"; +import type { AgentGatewayAdmissionFacade } from "./gateway"; +import type { AgentOperationCancellationFacade } from "./service"; +import type { AgentOperationIdentity } from "./ledger"; + +export type AgentOperationKernelRejectionReason = + | Exclude["reason"], "not_found"> + | Extract["reason"] + | "not_found"; + +/** A durable actor decision rejected the exact operation. Callers must fail closed. */ +export class AgentOperationKernelRejectedError extends Error { + constructor( + readonly operation: ActorAgentOperationRequest["op"], + readonly reason: AgentOperationKernelRejectionReason, + ) { + super(`SessionKernel rejected Agent operation ${operation}: ${reason}`); + this.name = "AgentOperationKernelRejectedError"; + } +} + +/** The actor accepted a mutation but returned evidence that does not prove it. */ +export class AgentOperationKernelEvidenceError extends Error { + constructor(readonly operation: ActorAgentOperationRequest["op"], message: string) { + super(`Invalid SessionKernel Agent operation ${operation} evidence: ${message}`); + this.name = "AgentOperationKernelEvidenceError"; + } +} + +/** A quarantined session cannot authorize, settle, query, or cancel an operation. */ +export class AgentOperationKernelQuarantinedError extends Error { + constructor(readonly sessionId: string) { + super(`SessionKernel quarantined Agent operation session ${sessionId}`); + this.name = "AgentOperationKernelQuarantinedError"; + } +} + +/** + * Mutation transport failure is ambiguous. This error is deliberately non-retryable: + * replay, if desired, must be an explicit later call with the exact same request. + */ +export class AgentOperationKernelTransportError extends Error { + readonly ambiguous = true; + readonly retryable = false; + constructor( + readonly operation: ActorAgentOperationRequest["op"], + options: { cause: unknown }, + ) { + super(`Ambiguous SessionKernel transport while deciding Agent operation ${operation}`, options); + this.name = "AgentOperationKernelTransportError"; + } +} + +type ActorClient = Pick; + +export interface AgentOperationKernelFacades { + readonly admission: AgentGatewayAdmissionFacade; + readonly cancellation: AgentOperationCancellationFacade; + readonly queryAuthorized: ( + identity: Readonly, + ) => Promise; +} + +/** + * Import-inert schema-32 adapter. Every call is one bounded actor decision; provider + * and transcript work remains outside the actor mailbox. + */ +export class AgentOperationKernelFacade + implements AgentGatewayAdmissionFacade, AgentOperationCancellationFacade +{ + constructor(private readonly actor: ActorClient) {} + + async admit(identity: AgentOperationIdentity): Promise<{ accepted: boolean }> { + const request = { op: "admit", identity: actorIdentity(identity) } as const; + const result = await this.decide(request); + if (!result.accepted) throw rejected(request.op, result.reason); + assertReceipt(request.op, request.identity, result.receipt); + return { accepted: true }; + } + + async settle( + identity: AgentOperationIdentity, + gatewayReceiptDigest: `sha256:${string}`, + terminal: Readonly, + ): Promise { + await this.terminal("settle", identity, gatewayReceiptDigest, terminal); + } + + async indeterminate( + identity: AgentOperationIdentity, + gatewayReceiptDigest: `sha256:${string}`, + terminal: Readonly, + ): Promise { + await this.terminal("indeterminate", identity, gatewayReceiptDigest, terminal); + } + + async request( + identity: AgentOperationIdentity, + cancelId: string, + reason: AgentHostCancelIntent["reason"], + ): Promise<"requested" | "too_late"> { + const request = { + op: "cancel", + identity: actorIdentity(identity), + cancelId, + reason, + } as const; + const result = await this.decide(request); + if (!result.accepted) throw rejected(request.op, result.reason); + const decodedIntent = decodeAgentOperationCancellationIntent(result.intent); + const expected = JSON.stringify({ + identity: request.identity, + cancelId, + reason, + disposition: result.intent.disposition, + }); + const actual = decodedIntent && JSON.stringify({ + identity: decodedIntent.identity, + cancelId: decodedIntent.cancelId, + reason: decodedIntent.reason, + disposition: decodedIntent.disposition, + }); + if (actual !== expected) + throw new AgentOperationKernelEvidenceError("cancel", "intent mismatch"); + return result.intent.disposition; + } + + async queryAuthorized( + identity: Readonly, + ): Promise { + const request = { op: "query", identity: actorIdentity(identity) } as const; + const result = await this.decide(request); + if (!result.accepted) { + if (result.reason === "not_found") return undefined; + throw rejected(request.op, result.reason); + } + assertReceipt(request.op, request.identity, result.receipt); + return result.receipt; + } + + asFacades(): AgentOperationKernelFacades { + return Object.freeze({ + admission: this, + cancellation: this, + queryAuthorized: this.queryAuthorized.bind(this), + }); + } + + private async terminal( + op: "settle" | "indeterminate", + identity: AgentOperationIdentity, + gatewayReceiptDigest: `sha256:${string}`, + terminal: Readonly, + ): Promise { + if ( + (identity.kind === "model" && terminal.pendingToolUseEntryIds === undefined) || + (identity.kind === "mcp" && terminal.pendingToolUseEntryIds !== undefined) + ) + throw new AgentOperationKernelEvidenceError(op, "invalid pending tool evidence"); + const request: AgentOperationTerminal = { + op, + identity: actorIdentity(identity), + gatewayReceiptDigest, + outputDigest: terminal.outputDigest, + outcomeCode: terminal.outcomeCode, + transcriptReceipts: terminal.transcriptRefs, + ...(identity.kind === "model" + ? { pendingToolUseEntryIds: terminal.pendingToolUseEntryIds! } + : {}), + }; + const result = await this.decide(request); + if (!result.accepted) throw rejected(op, result.reason); + assertReceipt(op, request.identity, result.receipt); + if (result.receipt.state !== opState(op)) + throw new AgentOperationKernelEvidenceError(op, "terminal state mismatch"); + const replay: AgentOperationTerminal = { + op, + identity: result.receipt.identity, + gatewayReceiptDigest: result.receipt.gatewayReceiptDigest!, + outputDigest: result.receipt.outputDigest!, + outcomeCode: result.receipt.outcomeCode!, + transcriptReceipts: result.receipt.transcriptReceipts!, + ...(identity.kind === "model" + ? { pendingToolUseEntryIds: result.receipt.pendingToolUseEntryIds! } + : {}), + }; + if ( + canonicalAgentOperationTerminal(request) !== + canonicalAgentOperationTerminal(replay) + ) + throw new AgentOperationKernelEvidenceError(op, "terminal evidence mismatch"); + } + + private async decide(request: T) { + try { + return await this.actor.decideAgentOperationAsync(request); + } catch (error) { + if (error instanceof AgentOperationKernelRejectedError || + error instanceof AgentOperationKernelEvidenceError || + error instanceof AgentOperationKernelQuarantinedError || + error instanceof AgentOperationKernelTransportError) + throw error; + if (error instanceof SessionKernelQuarantinedError) + throw new AgentOperationKernelQuarantinedError(error.sessionId); + // Even a retryable-labelled actor error is ambiguous for schema-32 commands. + if (error instanceof SessionKernelActorError || error instanceof Error) + throw new AgentOperationKernelTransportError(request.op, { cause: error }); + throw new AgentOperationKernelTransportError(request.op, { cause: error }); + } + } +} + +export function createAgentOperationKernelFacades( + actor: ActorClient, +): AgentOperationKernelFacades { + return new AgentOperationKernelFacade(actor).asFacades(); +} + +function actorIdentity(identity: Readonly): ActorAgentOperationIdentity { + const mapped = decodeAgentOperationIdentity({ + sessionId: identity.fence.sessionId, + runId: identity.fence.runId, + turnId: identity.fence.turnId, + generation: identity.fence.generation, + operationId: identity.operationId, + kind: identity.kind, + descriptorDigest: identity.descriptorDigest, + payloadDigest: identity.payloadDigest, + adapterId: identity.adapterId, + adapterVersion: identity.adapterVersion, + authorityHash: identity.authorityHash, + supervisorEpoch: identity.supervisorEpoch, + planHash: identity.planHash, + hostId: identity.hostId, + hostGeneration: identity.hostGeneration, + hostIncarnation: identity.hostIncarnation, + transcriptAnchor: identity.transcriptAnchor, + ...(identity.kind === "mcp" ? { toolUseEntryId: identity.toolUseEntryId } : {}), + }); + if (!mapped) + throw new AgentOperationKernelEvidenceError("admit", "invalid gateway identity"); + return mapped; +} + +function assertReceipt( + op: ActorAgentOperationRequest["op"], + identity: ActorAgentOperationIdentity, + receipt: ActorAgentOperationReceipt, +): void { + const decoded = decodeAgentOperationReceipt(receipt); + if (!decoded || JSON.stringify(decoded) !== JSON.stringify(receipt)) + throw new AgentOperationKernelEvidenceError(op, "malformed receipt"); + if ( + canonicalAgentOperationIdentity(identity) !== + canonicalAgentOperationIdentity(decoded.identity) + ) + throw new AgentOperationKernelEvidenceError(op, "identity mismatch"); +} + +function rejected( + operation: ActorAgentOperationRequest["op"], + reason: AgentOperationKernelRejectionReason, +) { + return new AgentOperationKernelRejectedError(operation, reason); +} + +function opState(op: "settle" | "indeterminate") { + return op === "settle" ? "settled" : "indeterminate"; +} diff --git a/packages/core/opensession-server/src/server/agent-operation/ledger.ts b/packages/core/opensession-server/src/server/agent-operation/ledger.ts new file mode 100644 index 0000000000..010e2aa21b --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/ledger.ts @@ -0,0 +1,408 @@ +import { decodeAgentOperationReceiptV1 } from "@tellahq/opensession-protocol/agent-operation"; +import type { + AgentAdapterReconciliationProofV1, + AgentOperationDescriptorV1, + AgentOperationDigest, + AgentOperationKernelTerminalV1, + AgentOperationKind, + AgentOperationOutcomeV1, + AgentOperationReceiptV1, + AgentOperationState, + AgentTranscriptAnchorV1, + AgentTranscriptReceiptRefV1, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentTurnFence } from "@tellahq/opensession-protocol/agent-host"; +import type { AgentOperationAuthorizedQuery } from "./authorized-query"; + +export interface AgentOperationIdentity { + operationId: string; + kind: AgentOperationKind; + fence: Readonly; + planHash: AgentOperationDigest; + authorityHash: AgentOperationDigest; + supervisorEpoch: number; + hostId: string; + hostGeneration: number; + hostIncarnation: string; + transcriptAnchor: Readonly; + toolUseEntryId?: string; + descriptor: AgentOperationDescriptorV1; + descriptorDigest: AgentOperationDigest; + payloadDigest: AgentOperationDigest; + adapterId: string; + adapterVersion: string; +} +export type AgentOperationQuarantineReason = + | "claim_identity_mismatch" + | "get_identity_mismatch" + | "transition_identity_mismatch"; +export interface AgentOperationTerminalReservation { + reservationId: string; + reason: AgentOperationIndeterminateReason; + reservedAtMs: number; +} +export interface AgentOperationRecord extends AgentOperationIdentity { + receipt: AgentOperationReceiptV1; + terminalReservation?: Readonly; + quarantineReason?: AgentOperationQuarantineReason; +} +export interface AgentOperationSettlement { + completedAtMs: number; + outcome: AgentOperationOutcomeV1; + transcriptRefs: readonly AgentTranscriptReceiptRefV1[]; + kernelTerminal: Readonly; + providerRequestRef?: string; + providerResponseRef?: string; +} +export type AgentOperationIndeterminateReason = + | "reconciliation_unsupported" + | "reconciliation_failed" + | "ambiguous_completion" + | "identity_mismatch" + | "cancellation_ambiguous" + | "timeout_ambiguous" + | "disconnect_ambiguous"; + +export interface AgentOperationLedger { + claimPrepared( + identity: AgentOperationIdentity, + acceptedAtMs: number, + ): Promise<{ record: AgentOperationRecord; claimed: boolean }>; + markExecuting( + identity: AgentOperationIdentity, + executingAtMs: number, + ): Promise; + settle( + identity: AgentOperationIdentity, + settlement: AgentOperationSettlement, + ): Promise; + reserveIndeterminate( + identity: AgentOperationIdentity, + reason: AgentOperationIndeterminateReason, + reservedAtMs: number, + ): Promise>; + markIndeterminate( + identity: AgentOperationIdentity, + reservation: Readonly, + completedAtMs: number, + kernelTerminal: Readonly, + ): Promise; + /** Both the primary key and every expected identity field are required. */ + getExact( + identity: AgentOperationIdentity, + ): Promise; + /** + * Reads a durable receipt using freshly verified supervision authority. + * Authorization mismatch is indistinguishable from absence and never mutates. + */ + queryAuthorized( + query: AgentOperationAuthorizedQuery, + ): Promise; + scanActive(): Promise; + retireSession(sessionId: string): Promise; + deleteSession(sessionId: string): Promise; + close(): Promise; +} + +export class AgentOperationConflictError extends Error { + constructor(message = "agent operation identity conflict") { + super(message); + this.name = "AgentOperationConflictError"; + } +} +export class AgentOperationTerminalReservedError extends Error { + constructor() { + super("agent operation terminal is reserved as indeterminate"); + this.name = "AgentOperationTerminalReservedError"; + } +} +export class AgentOperationNotFoundError extends Error { + constructor() { + super("agent operation not found for exact identity"); + this.name = "AgentOperationNotFoundError"; + } +} +export class AgentOperationTransitionError extends Error { + constructor(current: AgentOperationState, next: AgentOperationState) { + super(`illegal agent operation transition: ${current} -> ${next}`); + this.name = "AgentOperationTransitionError"; + } +} +export class AgentOperationLedgerFullError extends Error { + constructor() { + super("agent operation ledger is full"); + this.name = "AgentOperationLedgerFullError"; + } +} +export class AgentOperationSessionActiveError extends Error { + constructor() { + super("cannot retire a session with active agent operations"); + this.name = "AgentOperationSessionActiveError"; + } +} + +export interface ExecutingOperationReconciler { + reconcile(record: AgentOperationRecord): Promise< + | { + status: "settled"; + proof: AgentAdapterReconciliationProofV1; + settlement: AgentOperationSettlement; + } + | { status: "not_started"; proof: AgentAdapterReconciliationProofV1 } + | { + status: "indeterminate"; + reason: + | "reconciliation_unsupported" + | "reconciliation_failed" + | "ambiguous_completion"; + } + >; +} +/** + * Recover one inherited executing operation. There is deliberately no retry path: + * not_started proof is retained as indeterminate because executing was committed + * before invocation and this foundation cannot roll state backward. + */ +export async function reconcileExecutingOperation( + ledger: AgentOperationLedger, + record: AgentOperationRecord, + reconciler: ExecutingOperationReconciler | undefined, + createIndeterminateTerminal: ( + record: AgentOperationRecord, + reservation: Readonly, + ) => Promise>, + completedAtMs: number, +): Promise { + if (record.receipt.state !== "executing") + throw new AgentOperationTransitionError( + record.receipt.state, + "indeterminate", + ); + const authenticateReservation = async ( + expected: Readonly, + ): Promise< + | { terminal: AgentOperationRecord } + | { + record: AgentOperationRecord; + reservation: Readonly; + } + > => { + const latest = await ledger.getExact(record); + if (!latest) throw new AgentOperationNotFoundError(); + if ( + latest.receipt.state === "settled" || + latest.receipt.state === "indeterminate" + ) + return { terminal: latest }; + if ( + latest.receipt.state !== "executing" || + !latest.terminalReservation || + !sameTerminalReservation(latest.terminalReservation, expected) + ) + throw new AgentOperationConflictError( + "agent operation terminal reservation mismatch", + ); + return { + record: latest, + reservation: latest.terminalReservation, + }; + }; + const finalizeReservation = async ( + expected: Readonly, + ): Promise => { + const authenticated = await authenticateReservation(expected); + if ("terminal" in authenticated) return authenticated.terminal; + try { + // Callers bind append identity to reservationId, making restart and + // concurrent reservation recovery destination-idempotent. + const terminal = await createIndeterminateTerminal( + authenticated.record, + authenticated.reservation, + ); + return await ledger.markIndeterminate( + authenticated.record, + authenticated.reservation, + completedAtMs, + terminal, + ); + } catch (error) { + const latest = await ledger.getExact(authenticated.record); + if (latest?.receipt.state === "indeterminate") return latest; + throw error; + } + }; + const failClosed = async ( + reason: + | "reconciliation_unsupported" + | "reconciliation_failed" + | "ambiguous_completion", + ): Promise => { + try { + // This durable reservation wins terminal ownership before transcript I/O. + // Settlement checks the same row and cannot commit once it exists. + return await finalizeReservation( + await ledger.reserveIndeterminate(record, reason, completedAtMs), + ); + } catch (error) { + const latest = await ledger.getExact(record); + if ( + latest && + (latest.receipt.state === "settled" || + latest.receipt.state === "indeterminate") + ) + return latest; + throw error; + } + }; + // Once terminal ownership is reserved, never consult the adapter again. + if (record.terminalReservation) + return finalizeReservation(record.terminalReservation); + if (!reconciler) return failClosed("reconciliation_unsupported"); + let result: unknown; + try { + // Snapshot once so adapter accessors/Proxies cannot change shape between + // runtime validation and durable settlement. + result = structuredClone(await reconciler.reconcile(record)); + } catch { + return failClosed("reconciliation_failed"); + } + if (!plain(result) || typeof result.status !== "string") + return failClosed("reconciliation_failed"); + if (result.status === "settled") { + if ( + !exact(result, ["status", "proof", "settlement"]) || + !proofMatches(record, result.proof) || + !validSettlement(record, result.settlement) || + !plain(result.proof) || + result.proof.providerRequestRef !== + (result.settlement as AgentOperationSettlement).providerRequestRef || + result.proof.providerResponseRef !== + (result.settlement as AgentOperationSettlement).providerResponseRef + ) + return failClosed("reconciliation_failed"); + try { + return await ledger.settle( + record, + result.settlement as AgentOperationSettlement, + ); + } catch { + return failClosed("reconciliation_failed"); + } + } + if (result.status === "not_started") { + if ( + !exact(result, ["status", "proof"]) || + !proofMatches(record, result.proof) + ) + return failClosed("reconciliation_failed"); + return failClosed("ambiguous_completion"); + } + if ( + result.status === "indeterminate" && + exact(result, ["status", "reason"]) && + (result.reason === "reconciliation_unsupported" || + result.reason === "reconciliation_failed" || + result.reason === "ambiguous_completion") + ) + return failClosed(result.reason); + return failClosed("reconciliation_failed"); +} +function sameTerminalReservation( + a: Readonly, + b: Readonly, +): boolean { + return ( + a.reservationId === b.reservationId && + a.reason === b.reason && + a.reservedAtMs === b.reservedAtMs + ); +} +const plain = (value: unknown): value is Record => + !!value && + typeof value === "object" && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype; +const exact = (value: Record, keys: readonly string[]) => + Object.keys(value).length === keys.length && + Object.keys(value).every((key) => keys.includes(key)); +function proofMatches( + record: AgentOperationRecord, + candidate: unknown, +): candidate is AgentAdapterReconciliationProofV1 { + if ( + !plain(candidate) || + !Object.keys(candidate).every((key) => + [ + "adapterId", + "adapterVersion", + "operationId", + "kind", + "fence", + "planHash", + "authorityHash", + "descriptorDigest", + "payloadDigest", + "providerRequestRef", + "providerResponseRef", + ].includes(key), + ) || + !plain(candidate.fence) + ) + return false; + const proof = candidate as unknown as AgentAdapterReconciliationProofV1; + return ( + proof.adapterId === record.adapterId && + proof.adapterVersion === record.adapterVersion && + proof.operationId === record.operationId && + proof.kind === record.kind && + proof.fence.sessionId === record.fence.sessionId && + proof.fence.runId === record.fence.runId && + proof.fence.turnId === record.fence.turnId && + proof.fence.generation === record.fence.generation && + proof.planHash === record.planHash && + proof.authorityHash === record.authorityHash && + proof.descriptorDigest === record.descriptorDigest && + proof.payloadDigest === record.payloadDigest && + (proof.providerRequestRef === undefined || + typeof proof.providerRequestRef === "string") && + (proof.providerResponseRef === undefined || + typeof proof.providerResponseRef === "string") + ); +} +function validSettlement( + record: AgentOperationRecord, + candidate: unknown, +): candidate is AgentOperationSettlement { + if ( + !plain(candidate) || + !Object.keys(candidate).every((key) => + [ + "completedAtMs", + "outcome", + "transcriptRefs", + "kernelTerminal", + "providerRequestRef", + "providerResponseRef", + ].includes(key), + ) + ) + return false; + return !!decodeAgentOperationReceiptV1({ + ...record.receipt, + state: "settled", + completedAtMs: candidate.completedAtMs, + outcome: candidate.outcome, + transcriptRefs: candidate.transcriptRefs, + kernelTerminal: candidate.kernelTerminal, + providerRef: { + adapterId: record.adapterId, + adapterVersion: record.adapterVersion, + ...(candidate.providerRequestRef === undefined + ? {} + : { requestId: candidate.providerRequestRef }), + ...(candidate.providerResponseRef === undefined + ? {} + : { responseId: candidate.providerResponseRef }), + }, + }); +} diff --git a/packages/core/opensession-server/src/server/agent-operation/mcp-adapter.test.ts b/packages/core/opensession-server/src/server/agent-operation/mcp-adapter.test.ts new file mode 100644 index 0000000000..3861136ea2 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/mcp-adapter.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, test } from "bun:test"; +import { AgentGatewayAmbiguousExecutionError } from "./gateway"; +import type { AgentOperationIdentity } from "./ledger"; +import type { McpRuntime, McpRuntimeTool } from "../mcp-runtime"; +import { + MAX_MCP_AGENT_TRANSCRIPT_BYTES, + MCP_AGENT_OPERATION_RECONCILER, + MCP_AGENT_OPERATION_REQUEST_VERSION, + McpAgentOperationAmbiguityError, + McpTurnRuntimeRegistry, + createMcpAgentOperationAdapter, +} from "./mcp-adapter"; + +const digest = (char: string) => `sha256:${char.repeat(64)}` as const; +const freeze = (value: T): T => { + if (value && typeof value === "object") { + for (const child of Object.values(value as Record)) + freeze(child); + Object.freeze(value); + } + return value; +}; +const fence = (overrides = {}) => + freeze({ + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, + ...overrides, + }); +function identity( + overrides: Partial = {}, +): AgentOperationIdentity { + const turnFence = overrides.fence ?? fence(); + return freeze({ + operationId: "operation-1", + kind: "mcp" as const, + fence: turnFence, + planHash: digest("a"), + authorityHash: digest("b"), + supervisorEpoch: 1, + hostId: "host-1", + hostGeneration: 1, + hostIncarnation: "incarnation-1", + transcriptAnchor: freeze({ + throughChangeSeq: 1, + entryIds: freeze(["entry-1"]), + digest: digest("c"), + }), + toolUseEntryId: "entry-1", + descriptor: freeze({ + version: 1 as const, + kind: "mcp" as const, + toolUseEntryId: "entry-1", + toolUseId: "tool-use-1", + server: "search", + tool: "lookup", + argumentsDigest: digest("d"), + adapterRequestVersion: MCP_AGENT_OPERATION_REQUEST_VERSION, + }), + descriptorDigest: digest("e"), + payloadDigest: digest("f"), + adapterId: "mcp-runtime", + adapterVersion: "1.0", + ...overrides, + }); +} +const payload = (args: Record = { query: "safe" }) => + freeze({ arguments: freeze(args) }); +const listedTool = (overrides: Partial = {}): McpRuntimeTool => + freeze({ + id: "search_lookup", + server: "search", + name: "lookup", + label: "Lookup", + description: "Lookup", + inputSchema: freeze({ type: "object" }), + ...overrides, + }); +function runtime( + options: { + tools?: readonly McpRuntimeTool[]; + call?: McpRuntime["callExact"]; + close?: () => Promise; + } = {}, +): McpRuntime { + return { + hasCatalog: true, + async catalog() { + return options.tools ?? [listedTool()]; + }, + callExact: + options.call ?? + (async () => ({ content: [{ type: "text", text: "result" }] })), + close: options.close ?? (async () => {}), + }; +} + +async function installed(value: McpRuntime, turnFence = fence()) { + const registry = new McpTurnRuntimeRegistry(); + const owner = registry.register(turnFence, value); + return { + registry, + owner, + adapter: createMcpAgentOperationAdapter(registry), + turnFence, + }; +} + +describe("turn-scoped MCP Agent operation adapter", () => { + test("makes exactly one physical exact-identity call with descriptor tool-use identity", async () => { + const calls: unknown[] = []; + const controller = new AbortController(); + const setup = await installed( + runtime({ + call: async (...args) => { + calls.push(args); + return { content: [{ type: "text", text: "done" }] }; + }, + }), + ); + const result = await setup.adapter.execute( + freeze({ + identity: identity({ fence: setup.turnFence }), + payload: payload(), + }), + controller.signal, + ); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + "search_lookup", + { query: "safe" }, + { toolCallId: "tool-use-1", signal: controller.signal }, + ]); + expect(result).toEqual({ + outcome: { status: "succeeded", code: "ok" }, + transcript: { kind: "mcp", content: [{ type: "text", text: "done" }] }, + }); + }); + + test("denied, missing, duplicate and crossover catalog identities make zero calls", async () => { + for (const tools of [ + [], + [listedTool({ name: "denied" })], + [listedTool(), listedTool()], + [listedTool({ server: "other" })], + [listedTool({ id: "other_lookup" })], + ]) { + let calls = 0; + const setup = await installed( + runtime({ + tools, + call: async () => { + calls++; + return { content: [] }; + }, + }), + ); + const result = await setup.adapter.execute( + freeze({ + identity: identity({ fence: setup.turnFence }), + payload: payload(), + }), + new AbortController().signal, + ); + expect(calls).toBe(0); + expect(result.outcome).toEqual({ status: "failed", code: "tool_error" }); + } + }); + + test("rejects request-version mismatch before catalog or call", async () => { + let catalogs = 0; + let calls = 0; + const value = runtime({ + call: async () => { + calls++; + return { content: [] }; + }, + }); + value.catalog = async () => { + catalogs++; + return [listedTool()]; + }; + const setup = await installed(value); + const base = identity({ fence: setup.turnFence }); + const result = await setup.adapter.execute( + freeze({ + identity: identity({ + fence: setup.turnFence, + descriptor: freeze({ + ...base.descriptor, + adapterRequestVersion: "other", + }), + }), + payload: payload(), + }), + new AbortController().signal, + ); + expect(result.outcome.status).toBe("failed"); + expect({ catalogs, calls }).toEqual({ catalogs: 0, calls: 0 }); + }); + + test("rejects prototypes, accessors and extra payload keys without executing getters", async () => { + let calls = 0; + let getterCalls = 0; + const setup = await installed( + runtime({ + call: async () => { + calls++; + return { content: [] }; + }, + }), + ); + const accessor = Object.create(null); + Object.defineProperty(accessor, "arguments", { + enumerable: true, + get: () => { + getterCalls++; + return {}; + }, + }); + Object.freeze(accessor); + const hostile = [ + freeze( + Object.assign(Object.create({ inherited: true }), { + arguments: freeze({}), + }), + ), + accessor, + freeze({ arguments: freeze({}), extra: true }), + { arguments: freeze({}) }, + ]; + for (const raw of hostile) { + const result = await setup.adapter.execute( + { identity: identity({ fence: setup.turnFence }), payload: raw }, + new AbortController().signal, + ); + expect(result.outcome.status).toBe("failed"); + } + expect({ calls, getterCalls }).toEqual({ calls: 0, getterCalls: 0 }); + }); + + test("a signal aborted before invocation makes zero calls", async () => { + let calls = 0; + const setup = await installed( + runtime({ + call: async () => { + calls++; + return { content: [] }; + }, + }), + ); + const controller = new AbortController(); + controller.abort(); + const result = await setup.adapter.execute( + freeze({ + identity: identity({ fence: setup.turnFence }), + payload: payload(), + }), + controller.signal, + ); + expect(result.outcome).toEqual({ status: "cancelled", code: "cancelled" }); + expect(calls).toBe(0); + }); + + test("cancellation after the physical call begins is typed ambiguous", async () => { + const controller = new AbortController(); + let calls = 0; + const setup = await installed( + runtime({ + call: async () => { + calls++; + controller.abort(); + const error = new Error("aborted"); + error.name = "AbortError"; + throw error; + }, + }), + ); + const promise = setup.adapter.execute( + freeze({ + identity: identity({ fence: setup.turnFence }), + payload: payload(), + }), + controller.signal, + ); + await expect(promise).rejects.toBeInstanceOf( + McpAgentOperationAmbiguityError, + ); + await expect(promise).rejects.toBeInstanceOf( + AgentGatewayAmbiguousExecutionError, + ); + await expect(promise).rejects.toMatchObject({ + reason: "cancellation_ambiguous", + }); + expect(calls).toBe(1); + }); + + test("timeout and disconnect after invocation are typed ambiguous and reconciliation fails closed", async () => { + for (const [message, reason] of [ + ["request timed out", "timeout_ambiguous"], + ["connection closed", "disconnect_ambiguous"], + ] as const) { + let calls = 0; + const setup = await installed( + runtime({ + call: async () => { + calls++; + throw new Error(message); + }, + }), + ); + const promise = setup.adapter.execute( + freeze({ + identity: identity({ fence: setup.turnFence }), + payload: payload(), + }), + new AbortController().signal, + ); + await expect(promise).rejects.toMatchObject({ + name: "McpAgentOperationAmbiguityError", + reason, + }); + expect(calls).toBe(1); + } + expect(await MCP_AGENT_OPERATION_RECONCILER.reconcile({} as never)).toEqual( + { + status: "indeterminate", + reason: "reconciliation_unsupported", + }, + ); + }); + + test("deterministic tool errors fail without exposing raw error material", async () => { + const setup = await installed( + runtime({ + call: async () => { + throw new Error("secret credential abc"); + }, + }), + ); + const result = await setup.adapter.execute( + freeze({ + identity: identity({ fence: setup.turnFence }), + payload: payload(), + }), + new AbortController().signal, + ); + expect(result.outcome).toEqual({ status: "failed", code: "tool_error" }); + expect(JSON.stringify(result)).not.toContain("secret credential abc"); + }); + + test("normalizes text and images into bounded ephemeral transcript content", async () => { + const setup = await installed( + runtime({ + call: async () => ({ + content: [ + { + type: "text", + text: "x".repeat(MAX_MCP_AGENT_TRANSCRIPT_BYTES * 2), + }, + { + type: "image", + data: "y".repeat(MAX_MCP_AGENT_TRANSCRIPT_BYTES * 2), + mimeType: "image/png", + }, + ], + }), + }), + ); + const result = await setup.adapter.execute( + freeze({ + identity: identity({ fence: setup.turnFence }), + payload: payload(), + }), + new AbortController().signal, + ); + expect( + Buffer.byteLength(JSON.stringify(result.transcript)), + ).toBeLessThanOrEqual(MAX_MCP_AGENT_TRANSCRIPT_BYTES); + expect(JSON.stringify(result.transcript)).not.toContain("server"); + expect(Object.isFrozen(result.transcript)).toBe(true); + }); + + test("isolates full fences and gives close/unregister ownership only to the registration", async () => { + let closes = 0; + let callsA = 0; + let callsB = 0; + const registry = new McpTurnRuntimeRegistry(); + const a = fence(); + const b = fence({ generation: 2 }); + const ownerA = registry.register( + a, + runtime({ + call: async () => { + callsA++; + return { content: [] }; + }, + close: async () => { + closes++; + }, + }), + ); + registry.register( + b, + runtime({ + call: async () => { + callsB++; + return { content: [] }; + }, + }), + ); + const adapter = createMcpAgentOperationAdapter(registry); + await adapter.execute( + freeze({ identity: identity({ fence: b }), payload: payload() }), + new AbortController().signal, + ); + expect({ callsA, callsB }).toEqual({ callsA: 0, callsB: 1 }); + await Promise.all([ownerA.close(), ownerA.close()]); + expect(closes).toBe(1); + expect(registry.get(a)).toBeUndefined(); + expect(() => registry.register(a, runtime())).toThrow(); + expect(Object.keys(ownerA)).toEqual(["close"]); + expect(JSON.stringify({ registry, ownerA, adapter })).not.toMatch( + /credential|config|grant/i, + ); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/mcp-adapter.ts b/packages/core/opensession-server/src/server/agent-operation/mcp-adapter.ts new file mode 100644 index 0000000000..a6eae24714 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/mcp-adapter.ts @@ -0,0 +1,364 @@ +import type { AgentMcpOperationDescriptorV1 } from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentTurnFence } from "@tellahq/opensession-protocol/agent-host"; +import type { + McpRuntime, + McpRuntimeCallResult, + McpRuntimeContent, +} from "../mcp-runtime"; +import { + AgentGatewayAmbiguousExecutionError, + type AgentGatewayAdapter, + type AgentGatewayAdapterResult, +} from "./gateway"; +import type { + AgentOperationIdentity, + ExecutingOperationReconciler, +} from "./ledger"; + +export const MCP_AGENT_OPERATION_ADAPTER_ID = "mcp-runtime"; +export const MCP_AGENT_OPERATION_ADAPTER_VERSION = "1.0"; +export const MCP_AGENT_OPERATION_REQUEST_VERSION = "v1"; +export const MAX_MCP_AGENT_TRANSCRIPT_BYTES = 64 * 1024; +const MAX_MCP_AGENT_CONTENT_BLOCKS = 64; +const TRUNCATED = "…[truncated]"; +const OMITTED_IMAGE = + "[image omitted: MCP result exceeded the transcript limit]"; + +function fenceKey(fence: Readonly): string { + return JSON.stringify([ + fence.sessionId, + fence.runId, + fence.turnId, + fence.generation, + ]); +} + +function exactFence(value: Readonly): boolean { + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + return ( + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) && + keys.length === 4 && + keys.every( + (key) => + typeof key === "string" && + ["sessionId", "runId", "turnId", "generation"].includes(key) && + "value" in descriptors[key]! && + descriptors[key]!.enumerable, + ) && + typeof descriptors.sessionId?.value === "string" && + typeof descriptors.runId?.value === "string" && + typeof descriptors.turnId?.value === "string" && + Number.isSafeInteger(descriptors.generation?.value) && + (descriptors.generation?.value as number) >= 0 + ); +} + +export interface McpTurnRuntimeRegistration { + /** Removes this exact turn runtime and closes it. Safe to call repeatedly. */ + close(): Promise; +} + +/** + * Import-inert registry of credential-bearing runtimes owned by their turns. + * + * The registry never creates a runtime and the adapter can only borrow one. + * Registration returns the sole close capability; a fence remains consumed + * after close so a stale owner cannot install a replacement runtime. + */ +export class McpTurnRuntimeRegistry { + readonly #active = new Map(); + readonly #consumed = new Set(); + + register( + fence: Readonly, + runtime: McpRuntime, + ): McpTurnRuntimeRegistration { + if (!exactFence(fence)) throw new TypeError("invalid MCP turn fence"); + const key = fenceKey(fence); + if (this.#consumed.has(key)) + throw new Error("MCP runtime already registered for this turn"); + this.#consumed.add(key); + this.#active.set(key, runtime); + let closePromise: Promise | undefined; + return Object.freeze({ + close: () => { + if (!closePromise) { + this.#active.delete(key); + closePromise = Promise.resolve().then(() => runtime.close()); + } + return closePromise; + }, + }); + } + + /** Adapter-only borrowing boundary. It transfers neither runtime nor close ownership. */ + get(fence: Readonly): McpRuntime | undefined { + if (!exactFence(fence)) return undefined; + return this.#active.get(fenceKey(fence)); + } +} + +export type McpAgentOperationAmbiguityReason = + "cancellation_ambiguous" | "timeout_ambiguous" | "disconnect_ambiguous"; + +export class McpAgentOperationAmbiguityError extends AgentGatewayAmbiguousExecutionError { + declare readonly reason: McpAgentOperationAmbiguityReason; + constructor(reason: McpAgentOperationAmbiguityReason) { + super(reason); + this.name = "McpAgentOperationAmbiguityError"; + } +} + +function immutableJson(value: unknown): value is Record { + const seen = new Set(); + let values = 0; + const visit = (item: unknown, depth: number): boolean => { + if (++values > 2_048 || depth > 12) return false; + if ( + item === null || + typeof item === "string" || + typeof item === "boolean" || + (typeof item === "number" && Number.isFinite(item)) + ) + return true; + if (!item || typeof item !== "object" || seen.has(item)) return false; + if (!Object.isFrozen(item)) return false; + seen.add(item); + const descriptors = Object.getOwnPropertyDescriptors(item); + const keys = Reflect.ownKeys(descriptors); + if (Array.isArray(item)) { + if ( + Object.getPrototypeOf(item) !== Array.prototype || + keys.length !== item.length + 1 + ) + return false; + for (let index = 0; index < item.length; index++) { + const descriptor = descriptors[String(index)]; + if ( + !descriptor || + !("value" in descriptor) || + !descriptor.enumerable || + descriptor.value === undefined || + !visit(descriptor.value, depth + 1) + ) + return false; + } + } else { + const prototype = Object.getPrototypeOf(item); + if (prototype !== Object.prototype && prototype !== null) return false; + for (const key of keys) { + if (typeof key !== "string") return false; + const descriptor = descriptors[key]; + if ( + !descriptor || + !("value" in descriptor) || + !descriptor.enumerable || + descriptor.value === undefined || + /^(?:__proto__|prototype|constructor)$/.test(key) || + !visit(descriptor.value, depth + 1) + ) + return false; + } + } + seen.delete(item); + return true; + }; + return visit(value, 0) && !Array.isArray(value); +} + +function decodeArgumentsPayload( + value: unknown, +): Record | undefined { + if (!immutableJson(value)) return; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if ( + keys.length !== 1 || + keys[0] !== "arguments" || + !("value" in descriptors.arguments!) || + !immutableJson(descriptors.arguments!.value) + ) + return; + return descriptors.arguments!.value; +} + +function transcriptBytes(content: readonly McpRuntimeContent[]): number { + return Buffer.byteLength(JSON.stringify({ kind: "mcp", content })); +} + +function boundedText( + content: readonly McpRuntimeContent[], + text: string, +): string | undefined { + const full = { type: "text" as const, text }; + if (transcriptBytes([...content, full]) <= MAX_MCP_AGENT_TRANSCRIPT_BYTES) + return text; + let low = 0; + let high = text.length; + let fit: string | undefined; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const candidate = `${text.slice(0, middle)}${TRUNCATED}`; + if ( + transcriptBytes([...content, { type: "text", text: candidate }]) <= + MAX_MCP_AGENT_TRANSCRIPT_BYTES + ) { + fit = candidate; + low = middle + 1; + } else high = middle - 1; + } + return fit; +} + +function boundedTranscript(result: McpRuntimeCallResult): Readonly<{ + kind: "mcp"; + content: readonly McpRuntimeContent[]; +}> { + const content: McpRuntimeContent[] = []; + for (const block of result.content.slice(0, MAX_MCP_AGENT_CONTENT_BLOCKS)) { + if (block.type === "text") { + const text = boundedText(content, block.text); + if (text === undefined) break; + content.push(Object.freeze({ type: "text", text })); + if (text !== block.text) break; + continue; + } + const image = { + type: "image" as const, + data: block.data, + mimeType: block.mimeType, + }; + if ( + transcriptBytes([...content, image]) <= MAX_MCP_AGENT_TRANSCRIPT_BYTES + ) { + content.push(Object.freeze(image)); + continue; + } + const omitted = boundedText(content, OMITTED_IMAGE); + if (omitted !== undefined) + content.push(Object.freeze({ type: "text", text: omitted })); + break; + } + if (result.content.length > MAX_MCP_AGENT_CONTENT_BLOCKS) { + const omitted = boundedText(content, "[additional MCP content omitted]"); + if (omitted !== undefined) + content.push(Object.freeze({ type: "text", text: omitted })); + } + return Object.freeze({ kind: "mcp", content: Object.freeze(content) }); +} + +function ambiguityReason( + error: unknown, + signal: AbortSignal, +): McpAgentOperationAmbiguityReason | undefined { + if (signal.aborted || (error instanceof Error && error.name === "AbortError")) + return "cancellation_ambiguous"; + const text = + error instanceof Error ? `${error.name} ${error.message}` : String(error); + if (/time(?:d?\s*out|out)|deadline/i.test(text)) return "timeout_ambiguous"; + if ( + /disconnect|connection (?:closed|lost|reset)|socket|broken pipe|econnreset|eof/i.test( + text, + ) + ) + return "disconnect_ambiguous"; +} + +function terminalResult( + status: "failed" | "cancelled", + code: "tool_error" | "cancelled", + text: string, +): AgentGatewayAdapterResult { + return Object.freeze({ + outcome: Object.freeze({ status, code }), + transcript: Object.freeze({ + kind: "mcp", + content: Object.freeze([Object.freeze({ type: "text", text })]), + }), + }); +} + +const failedResult = () => + terminalResult("failed", "tool_error", "MCP tool call failed"); +const cancelledResult = () => + terminalResult("cancelled", "cancelled", "MCP tool call cancelled"); + +function mcpDescriptor( + identity: Readonly, +): AgentMcpOperationDescriptorV1 | undefined { + return identity.kind === "mcp" && + identity.descriptor.kind === "mcp" && + identity.toolUseEntryId === identity.descriptor.toolUseEntryId && + identity.adapterId === MCP_AGENT_OPERATION_ADAPTER_ID && + identity.adapterVersion === MCP_AGENT_OPERATION_ADAPTER_VERSION + ? identity.descriptor + : undefined; +} + +/** Executing MCP calls have no durable provider receipt to query. Fail closed. */ +export const MCP_AGENT_OPERATION_RECONCILER: ExecutingOperationReconciler = + Object.freeze({ + async reconcile() { + return Object.freeze({ + status: "indeterminate" as const, + reason: "reconciliation_unsupported" as const, + }); + }, + }); + +/** Hardened, turn-scoped MCP adapter. */ +export function createMcpAgentOperationAdapter( + registry: McpTurnRuntimeRegistry, +): AgentGatewayAdapter { + return Object.freeze({ + id: MCP_AGENT_OPERATION_ADAPTER_ID, + version: MCP_AGENT_OPERATION_ADAPTER_VERSION, + async execute( + request: Parameters[0], + signal: AbortSignal, + ) { + const descriptor = mcpDescriptor(request.identity); + if ( + !descriptor || + descriptor.adapterRequestVersion !== MCP_AGENT_OPERATION_REQUEST_VERSION + ) + return failedResult(); + const args = decodeArgumentsPayload(request.payload); + if (!args) return failedResult(); + if (signal.aborted) return cancelledResult(); + const runtime = registry.get(request.identity.fence); + if (!runtime) return failedResult(); + let catalog: Awaited>; + try { + catalog = await runtime.catalog(); + } catch { + return signal.aborted ? cancelledResult() : failedResult(); + } + if (signal.aborted) return cancelledResult(); + const matches = catalog.filter( + (tool) => + tool.server === descriptor.server && + tool.name === descriptor.tool && + tool.id === `${descriptor.server}_${descriptor.tool}`, + ); + if (matches.length !== 1) return failedResult(); + + try { + const result = await runtime.callExact(matches[0]!.id, args, { + toolCallId: descriptor.toolUseId, + signal, + }); + return Object.freeze({ + outcome: Object.freeze({ status: "succeeded", code: "ok" }), + transcript: boundedTranscript(result), + }); + } catch (error) { + const reason = ambiguityReason(error, signal); + if (reason) throw new McpAgentOperationAmbiguityError(reason); + return failedResult(); + } + }, + }); +} diff --git a/packages/core/opensession-server/src/server/agent-operation/pi-model-adapter.test.ts b/packages/core/opensession-server/src/server/agent-operation/pi-model-adapter.test.ts new file mode 100644 index 0000000000..3565b4fc25 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/pi-model-adapter.test.ts @@ -0,0 +1,481 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + hashAgentModelPayloadV1, + hashAgentOperationDescriptorV1, + type AgentOperationDigest, + type AgentOperationRequestV1, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentHostSupervisionAuthorityV2 } from "@tellahq/opensession-protocol/agent-host"; +import type { PiRuntimeBinding } from "../pi-runtime-binding"; +import { + PI_MODEL_AGENT_OPERATION_ADAPTER_ID, + PI_MODEL_AGENT_OPERATION_ADAPTER_VERSION, + PI_MODEL_AGENT_OPERATION_RECONCILER, + PI_MODEL_AGENT_OPERATION_REQUEST_VERSION, + PiRuntimeBindingRegistry, + createPiModelAgentOperationAdapter, +} from "./pi-model-adapter"; +import { AgentGatewayGrantRegistry, encodeAgentGatewayPolicyHandle } from "./grants"; +import { AgentOperationGateway } from "./gateway"; +import { SQLiteAgentOperationLedger } from "./sqlite-ledger"; +import { + PiModelEventChainDecoder, + PiModelInvocationRegistry, + decodePiModelGatewayPayload, +} from "./pi-model-operation"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +const d = (c: string) => `sha256:${c.repeat(64)}` as AgentOperationDigest; +const ref = (n: number) => Buffer.alloc(32, n).toString("base64url"); +const fence = Object.freeze({ sessionId: "session-1", runId: "run-1", turnId: "turn-1", generation: 1 }); +const invocation = Object.freeze({ messages: Object.freeze([Object.freeze({ role: "user", content: "private prompt" })]) }); +const descriptor = Object.freeze({ + version: 1 as const, + kind: "model" as const, + stepId: "step-1", + transcript: Object.freeze({ throughChangeSeq: 1, entryIds: Object.freeze(["entry-1"]), digest: d("c") }), + modelPolicyHash: d("d"), + adapterRequestVersion: PI_MODEL_AGENT_OPERATION_REQUEST_VERSION, +}); +const identity = Object.freeze({ + operationId: "operation-1", + kind: "model" as const, + fence, + planHash: d("a"), + authorityHash: d("b"), + supervisorEpoch: 1, + hostId: "host-1", + hostGeneration: 1, + hostIncarnation: "incarnation-1", + transcriptAnchor: descriptor.transcript, + descriptor, + descriptorDigest: d("e"), + payloadDigest: d("f"), + adapterId: PI_MODEL_AGENT_OPERATION_ADAPTER_ID, + adapterVersion: PI_MODEL_AGENT_OPERATION_ADAPTER_VERSION, +}); + +function binding(): PiRuntimeBinding { + return { model: { provider: "anthropic", id: "claude-test" } } as PiRuntimeBinding; +} + +function setup(options: { executor?: (input: any) => Promise; bind?: boolean } = {}) { + const invocations = new PiModelInvocationRegistry({ now: () => 1_000 }); + const owner = invocations.register({ + fence, + operationId: identity.operationId, + bindingRef: ref(1), + invocationRef: ref(2), + descriptorDigest: identity.descriptorDigest, + invocation, + canonicalBytes: new TextEncoder().encode(JSON.stringify(invocation)), + deadlineMs: 2_000, + }); + const decoded = decodePiModelGatewayPayload(invocations, { + fence, + operationId: identity.operationId, + bindingRef: ref(1), + descriptorDigest: identity.descriptorDigest, + }, owner.reference)!; + const bindings = new PiRuntimeBindingRegistry(); + const runtimeBinding = binding(); + const bindingOwner = options.bind === false ? undefined : bindings.register({ + fence, + bindingRef: ref(1), + binding: runtimeBinding, + descriptorDigest: identity.descriptorDigest, + modelPolicyHash: descriptor.modelPolicyHash, + modelIdentity: { provider: "anthropic", id: "claude-test" }, + }); + let calls = 0; + const executor = { + async execute(input: any) { + calls++; + return options.executor?.(input) ?? { + outcome: Object.freeze({ status: "succeeded", code: "ok" }), + transcript: Object.freeze({ text: "ephemeral response" }), + providerRequestRef: "request-opaque", + providerResponseRef: "response-opaque", + }; + }, + }; + const adapter = createPiModelAgentOperationAdapter(bindings, invocations, executor); + const events: unknown[] = []; + const sink = { + async publish(event: unknown) { events.push(event); }, + async close() {}, + async fail() {}, + }; + return { adapter, decoded, owner, bindingOwner, runtimeBinding, events, sink, calls: () => calls, invocations }; +} + +function request(payload: unknown, overrides: Record = {}) { + return Object.freeze({ identity: Object.freeze({ ...identity, ...overrides }) as any, payload }); +} + +async function gatewayHarness(options: { + executor?: (input: any) => Promise; + forgedGrant?: boolean; + publish?: (event: unknown) => Promise; +} = {}) { + const root = mkdtempSync(join(tmpdir(), "pi-model-gateway-")); + roots.push(root); + const ledger = new SQLiteAgentOperationLedger({ dbPath: join(root, "ledger.sqlite") }); + let now = 10; + const grants = new AgentGatewayGrantRegistry({ now: () => now, entropy: () => "g".repeat(43) }); + const descriptorDigest = await hashAgentOperationDescriptorV1(descriptor); + const canonicalBytes = new TextEncoder().encode(JSON.stringify(invocation)); + const payloadDigest = await hashAgentModelPayloadV1(canonicalBytes); + const authority: AgentHostSupervisionAuthorityV2 = { + version: 2, + fence, + planHash: d("a"), + hostId: "host-000000000001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + supervisorEpoch: 1, + kernelServiceEpoch: "kernel-epoch-0001", + hostChallenge: "challenge-00000001", + audience: "opensession-agent-host", + purpose: "agent-host-supervision", + issuedAtMs: 1, + expiresAtMs: 1_000, + nonce: "nonce-000000000001", + keyId: "key-0000000000001", + }; + const invocationRegistry = new PiModelInvocationRegistry({ now: () => now }); + const invocationOwner = invocationRegistry.register({ + fence, + operationId: identity.operationId, + bindingRef: ref(1), + invocationRef: ref(2), + descriptorDigest, + invocation, + canonicalBytes, + deadlineMs: 900, + }); + const bindingRegistry = new PiRuntimeBindingRegistry(); + bindingRegistry.register({ + fence, + bindingRef: ref(1), + binding: binding(), + descriptorDigest, + modelPolicyHash: descriptor.modelPolicyHash, + modelIdentity: { provider: "anthropic", id: "claude-test" }, + }); + let calls = 0; + const adapter = createPiModelAgentOperationAdapter(bindingRegistry, invocationRegistry, { + async execute(input) { + calls++; + return options.executor?.(input) ?? { outcome: { status: "succeeded", code: "ok" }, transcript: { text: "ephemeral" } }; + }, + }); + const grant = grants.issue({ + operationId: identity.operationId, + kind: "model", + fence, + planHash: d("a"), + authorityHash: d("b"), + supervisorEpoch: 1, + hostId: authority.hostId, + hostGeneration: 1, + hostIncarnation: authority.hostIncarnation, + descriptorDigest, + payloadDigest, + transcriptAnchor: descriptor.transcript, + adapterId: adapter.id, + adapterVersion: options.forgedGrant ? "forged" : adapter.version, + deadlineMs: 800, + authorityExpiresAtMs: 900, + policyHandle: encodeAgentGatewayPolicyHandle("policy00000000001"), + }); + const requestValue: AgentOperationRequestV1 = { + version: 1, + operationId: identity.operationId, + kind: "model", + fence, + supervisionEnvelope: { + version: 1, + algorithm: "Ed25519", + domain: "opensession.agent-host.supervision.v2", + authorityBytes: "AQ", + signature: Buffer.alloc(64).toString("base64url"), + }, + dispatchGrant: grant, + descriptor, + descriptorDigest, + }; + const events: unknown[] = []; + const terminal = (appendId: string, outputDigest: AgentOperationDigest, outcomeCode: string) => { + const refs = [{ + appendId, + entryIds: [`entry-${appendId}`], + firstSeq: 3, + lastSeq: 3, + throughChangeSeq: 3, + requestDigest: d("1"), + }]; + return { refs, kernelTerminal: { outputDigest, outcomeCode, transcriptRefs: refs, pendingToolUseEntryIds: [] } }; + }; + const gateway = new AgentOperationGateway({ + ledger, + grants, + now: () => ++now, + verifySupervision: async () => ({ authority, authorityHash: d("b") }), + admission: { async admit() { return { accepted: true }; }, async settle() {}, async indeterminate() {} }, + adapterFor: () => adapter, + decodePayload: (kind, payload) => kind === "model" ? decodePiModelGatewayPayload(invocationRegistry, { fence, operationId: identity.operationId, bindingRef: ref(1), descriptorDigest }, payload) : undefined, + beginLiveExecution: async () => ({ + async publish(event) { events.push(event); await options.publish?.(event); }, + async close() {}, + async fail() {}, + }), + appendTerminal: async () => terminal("pi-terminal", d("7"), "ok"), + appendIndeterminateNotice: async (record, appendId) => terminal(appendId, d("8"), record.terminalReservation?.reason ?? "reconciliation_unsupported").kernelTerminal, + }); + return { gateway, request: requestValue, reference: invocationOwner.reference, invocationRegistry, calls: () => calls, events }; +} + +describe("Pi runtime binding ownership", () => { + test("is exact by fence and binding ref, cannot replace, and only owner unregisters", () => { + const registry = new PiRuntimeBindingRegistry(); + const value = binding(); + const input = { fence, bindingRef: ref(1), binding: value, descriptorDigest: d("e"), modelPolicyHash: d("d"), modelIdentity: { provider: "anthropic", id: "claude-test" } } as const; + const owner = registry.register(input); + expect(registry.get(fence, ref(1))?.binding).toBe(value); + expect(registry.get({ ...fence, generation: 2 }, ref(1))).toBeUndefined(); + expect(registry.get(fence, ref(2))).toBeUndefined(); + expect(() => registry.register(input)).toThrow("already registered"); + expect(owner.close()).toBe(true); + expect(owner.close()).toBe(false); + expect(registry.get(fence, ref(1))).toBeUndefined(); + expect(() => registry.register(input)).toThrow("already registered"); + }); + + test("rejects a binding whose selected model identity differs", () => { + const registry = new PiRuntimeBindingRegistry(); + expect(() => registry.register({ fence, bindingRef: ref(1), binding: binding(), descriptorDigest: d("e"), modelPolicyHash: d("d"), modelIdentity: { provider: "openai", id: "other" } })).toThrow("model identity mismatch"); + }); +}); + +describe("Pi model operation adapter", () => { + test("real gateway leaves invocation intact for a forged grant, then consumes once on exact authorized dispatch", async () => { + const forged = await gatewayHarness({ forgedGrant: true }); + await expect(forged.gateway.dispatch(forged.request, forged.reference)).rejects.toThrow("identity_mismatch"); + expect(forged.calls()).toBe(0); + const decoded = decodePiModelGatewayPayload(forged.invocationRegistry, { + fence, + operationId: identity.operationId, + bindingRef: ref(1), + descriptorDigest: forged.request.descriptorDigest, + }, forged.reference); + expect(decoded).toBeDefined(); + + const live = await gatewayHarness({ executor: async ({ invocation: seen, publish }) => { + expect(seen).toBe(invocation); + await publish(new Uint8Array([1])); + await publish(new Uint8Array([2])); + return { outcome: { status: "succeeded", code: "ok" }, transcript: { text: "ephemeral" } }; + } }); + const settled = await live.gateway.dispatch(live.request, live.reference); + expect(settled.receipt.state).toBe("settled"); + expect(live.calls()).toBe(1); + const replay = await live.gateway.dispatch(live.request, live.reference); + expect(replay.receipt.state).toBe("settled"); + expect(live.calls()).toBe(1); + const chain = new PiModelEventChainDecoder(identity.operationId); + expect(chain.decode(live.events[0])?.payloadBytes).toEqual(new Uint8Array([1])); + expect(chain.decode(live.events[1])?.payloadBytes).toEqual(new Uint8Array([2])); + }); + + test("real gateway immediately settles mid-call cancellation and stream loss as indeterminate", async () => { + let started!: () => void; + const physicalStarted = new Promise((resolve) => { started = resolve; }); + const cancelled = await gatewayHarness({ executor: async () => { + started(); + return new Promise(() => undefined); + } }); + const controller = new AbortController(); + const pendingCancellation = cancelled.gateway.dispatch(cancelled.request, cancelled.reference, controller.signal); + await physicalStarted; + controller.abort(); + const cancelledRecord = await pendingCancellation; + expect(cancelledRecord.receipt.state).toBe("indeterminate"); + expect(cancelledRecord.receipt.errorCode).toBe("cancellation_ambiguous"); + expect(cancelled.calls()).toBe(1); + + const stream = await gatewayHarness({ + executor: async ({ publish }) => { + await publish(new Uint8Array([1])); + return { outcome: { status: "succeeded", code: "ok" }, transcript: {} }; + }, + publish: async () => { throw new Error("transport closed"); }, + }); + const streamRecord = await stream.gateway.dispatch(stream.request, stream.reference); + expect(streamRecord.receipt.state).toBe("indeterminate"); + expect(streamRecord.receipt.errorCode).toBe("disconnect_ambiguous"); + expect(stream.calls()).toBe(1); + }); + + test("consumes once, calls once, preserves private references, and publishes ordered events with backpressure", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let secondPublishStarted = false; + const h = setup({ executor: async ({ binding: seenBinding, invocation: seenInvocation, publish }) => { + expect(seenBinding).toBe(h.runtimeBinding); + expect(seenInvocation).toBe(invocation); + await publish(new Uint8Array([1])); + secondPublishStarted = true; + await publish(new Uint8Array([2])); + return { outcome: { status: "succeeded", code: "ok" }, transcript: { text: "ephemeral" } }; + } }); + let publishes = 0; + h.sink.publish = async (event: unknown) => { + h.events.push(event); + publishes++; + if (publishes === 1) await gate; + }; + const pending = h.adapter.execute(request(h.decoded.value), new AbortController().signal, h.sink); + await Promise.resolve(); + expect(secondPublishStarted).toBe(false); + release(); + const result = await pending; + expect(result.outcome.status).toBe("succeeded"); + expect(h.calls()).toBe(1); + expect(secondPublishStarted).toBe(true); + const chain = new PiModelEventChainDecoder(identity.operationId); + expect(chain.decode(h.events[0])?.payloadBytes).toEqual(new Uint8Array([1])); + expect(chain.decode(h.events[1])?.payloadBytes).toEqual(new Uint8Array([2])); + const replay = await h.adapter.execute(request(h.decoded.value), new AbortController().signal, h.sink); + expect(replay.outcome.status).toBe("failed"); + expect(h.calls()).toBe(1); + }); + + test("serializes concurrent publishes and drains them when executor returns early", async () => { + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); + const h = setup({ executor: async ({ publish }) => { + void publish(new Uint8Array([1])); + void publish(new Uint8Array([2])); + return { outcome: { status: "succeeded", code: "ok" }, transcript: {} }; + } }); + let active = 0; + let maxActive = 0; + h.sink.publish = async (event: unknown) => { + active++; + maxActive = Math.max(maxActive, active); + h.events.push(event); + if (h.events.length === 1) await firstGate; + active--; + }; + let settled = false; + const pending = h.adapter.execute(request(h.decoded.value), new AbortController().signal, h.sink).then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + expect(h.events).toHaveLength(1); + releaseFirst(); + expect((await pending).outcome.status).toBe("succeeded"); + expect(maxActive).toBe(1); + const chain = new PiModelEventChainDecoder(identity.operationId); + expect(chain.decode(h.events[0])?.payloadBytes).toEqual(new Uint8Array([1])); + expect(chain.decode(h.events[1])?.payloadBytes).toEqual(new Uint8Array([2])); + }); + + test("propagates an unawaited queued publish failure as ambiguous", async () => { + const h = setup({ executor: async ({ publish }) => { + void publish(new Uint8Array([1])); + return { outcome: { status: "succeeded", code: "ok" }, transcript: {} }; + } }); + h.sink.publish = async () => { throw new Error("stream failed"); }; + await expect(h.adapter.execute(request(h.decoded.value), new AbortController().signal, h.sink)).rejects.toMatchObject({ reason: "disconnect_ambiguous" }); + expect(h.calls()).toBe(1); + }); + + test("closes the abort race between the initial check and listener installation", async () => { + const h = setup(); + let reads = 0; + const racingSignal = { + get aborted() { reads++; return false; }, + addEventListener(_type: string, listener: () => void) { listener(); }, + removeEventListener() {}, + } as unknown as AbortSignal; + const result = await h.adapter.execute(request(h.decoded.value), racingSignal, h.sink); + expect(result.outcome.status).toBe("cancelled"); + expect(reads).toBeGreaterThanOrEqual(2); + expect(h.calls()).toBe(0); + expect(h.invocations.peekForDecode((h.decoded.value as any).identity)).toBeDefined(); + }); + + test("pre-abort, missing sink, and missing binding make zero physical calls without consuming", async () => { + for (const mode of ["abort", "sink", "binding"] as const) { + const h = setup({ bind: mode !== "binding" }); + const controller = new AbortController(); + if (mode === "abort") controller.abort(); + const result = await h.adapter.execute(request(h.decoded.value), controller.signal, mode === "sink" ? undefined : h.sink); + expect(result.outcome.status).toBe(mode === "abort" ? "cancelled" : "failed"); + expect(h.calls()).toBe(0); + expect(h.invocations.peekForDecode((h.decoded.value as any).identity)).toBeDefined(); + } + }); + + test("cross-fence, binding, descriptor, request version, and accessors make zero calls", async () => { + const mutations = [ + { identity: { ...identity, fence: { ...fence, generation: 2 } }, payload: undefined }, + { identity, payload: Object.freeze({ ...(setup().decoded.value as any), identity: Object.freeze({ ...(setup().decoded.value as any).identity, bindingRef: ref(3) }) }) }, + { identity: { ...identity, descriptorDigest: d("9") }, payload: undefined }, + { identity: { ...identity, descriptor: Object.freeze({ ...descriptor, adapterRequestVersion: "v2" }) }, payload: undefined }, + ]; + for (const mutation of mutations) { + const h = setup(); + const result = await h.adapter.execute(request(mutation.payload ?? h.decoded.value, mutation.identity as any), new AbortController().signal, h.sink); + expect(result.outcome.status).toBe("failed"); + expect(h.calls()).toBe(0); + } + const h = setup(); + let reads = 0; + const evil = {} as any; + Object.defineProperties(evil, { + version: { enumerable: true, get() { reads++; return 1; } }, + identity: { enumerable: true, value: (h.decoded.value as any).identity }, + invocation: { enumerable: true, value: invocation }, + }); + const result = await h.adapter.execute(request(evil), new AbortController().signal, h.sink); + expect(result.outcome.status).toBe("failed"); + expect(reads).toBe(0); + expect(h.calls()).toBe(0); + }); + + test("mid-call cancellation, stream failure, timeout, and unknown loss are typed ambiguous", async () => { + const cases = [ + { error: new DOMException("aborted", "AbortError"), reason: "cancellation_ambiguous" }, + { error: new Error("deadline timed out"), reason: "timeout_ambiguous" }, + { error: new Error("provider vanished"), reason: "disconnect_ambiguous" }, + ] as const; + for (const item of cases) { + const h = setup({ executor: async () => { throw item.error; } }); + await expect(h.adapter.execute(request(h.decoded.value), new AbortController().signal, h.sink)).rejects.toMatchObject({ reason: item.reason }); + expect(h.calls()).toBe(1); + } + const h = setup({ executor: async ({ publish }) => { await publish(new Uint8Array([1])); throw new Error("unreachable"); } }); + h.sink.publish = async () => { throw new Error("stream closed"); }; + await expect(h.adapter.execute(request(h.decoded.value), new AbortController().signal, h.sink)).rejects.toMatchObject({ reason: "disconnect_ambiguous" }); + expect(h.calls()).toBe(1); + }); + + test("reconciliation is explicitly unsupported and diagnostics expose no private material", async () => { + await expect(PI_MODEL_AGENT_OPERATION_RECONCILER.reconcile(identity as any)).resolves.toEqual({ status: "indeterminate", reason: "reconciliation_unsupported" }); + const h = setup(); + const visible = JSON.stringify(h.owner.reference) + JSON.stringify({ id: h.adapter.id, version: h.adapter.version }); + for (const secret of ["private prompt", "messages", "apiKey", "provider", "claude-test"]) expect(visible).not.toContain(secret); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/pi-model-adapter.ts b/packages/core/opensession-server/src/server/agent-operation/pi-model-adapter.ts new file mode 100644 index 0000000000..4f991d905b --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/pi-model-adapter.ts @@ -0,0 +1,329 @@ +import type { + AgentModelOperationDescriptorV1, + AgentOperationDigest, + AgentOperationOutcomeV1, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentTurnFence } from "@tellahq/opensession-protocol/agent-host"; +import type { PiRuntimeBinding } from "../pi-runtime-binding"; +import { + AgentGatewayAmbiguousExecutionError, + type AgentGatewayAdapter, + type AgentGatewayAdapterResult, + type AgentGatewayLiveEventSink, +} from "./gateway"; +import type { + AgentOperationIdentity, + ExecutingOperationReconciler, +} from "./ledger"; +import { + encodePiModelEventV1, + type PiModelInvocationLookup, + type PiModelInvocationRegistry, + type PiModelPrivateAdapterPayloadV1, +} from "./pi-model-operation"; + +export const PI_MODEL_AGENT_OPERATION_ADAPTER_ID = "pi-bound-model"; +export const PI_MODEL_AGENT_OPERATION_ADAPTER_VERSION = "1.0"; +export const PI_MODEL_AGENT_OPERATION_REQUEST_VERSION = "v1"; + +const REF = /^[A-Za-z0-9_-]{43}$/; + +function fenceKey(fence: Readonly): string { + return JSON.stringify([ + fence.sessionId, + fence.runId, + fence.turnId, + fence.generation, + ]); +} + +function exactFence(value: Readonly): boolean { + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + return ( + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) && + keys.length === 4 && + keys.every( + (key) => + typeof key === "string" && + ["sessionId", "runId", "turnId", "generation"].includes(key) && + "value" in descriptors[key]! && + descriptors[key]!.enumerable, + ) && + typeof descriptors.sessionId?.value === "string" && + typeof descriptors.runId?.value === "string" && + typeof descriptors.turnId?.value === "string" && + Number.isSafeInteger(descriptors.generation?.value) && + (descriptors.generation?.value as number) >= 0 + ); +} + +interface BindingEntry { + readonly binding: PiRuntimeBinding; + readonly descriptorDigest: AgentOperationDigest; + readonly modelPolicyHash: AgentOperationDigest; + readonly provider: string; + readonly modelId: string; +} + +export interface PiRuntimeBindingRegistrationInput { + readonly fence: Readonly; + readonly bindingRef: string; + readonly binding: PiRuntimeBinding; + readonly descriptorDigest: AgentOperationDigest; + readonly modelPolicyHash: AgentOperationDigest; + /** Exact identity already selected on the binding. */ + readonly modelIdentity: Readonly<{ provider: string; id: string }>; +} + +export interface PiRuntimeBindingRegistration { + /** Owner-only removal capability. Safe to repeat. */ + close(): boolean; +} + +/** Turn-owned registry. It only borrows already-created bindings and never replaces one. */ +export class PiRuntimeBindingRegistry { + readonly #active = new Map(); + readonly #used = new Set(); + + register(input: PiRuntimeBindingRegistrationInput): PiRuntimeBindingRegistration { + if (!exactFence(input.fence) || !REF.test(input.bindingRef)) + throw new TypeError("invalid Pi binding identity"); + if (!input.binding || typeof input.binding !== "object") + throw new TypeError("invalid Pi runtime binding"); + if ( + input.binding.model.provider !== input.modelIdentity.provider || + input.binding.model.id !== input.modelIdentity.id + ) throw new Error("Pi binding model identity mismatch"); + const key = `${fenceKey(input.fence)}\0${input.bindingRef}`; + if (this.#used.has(key)) throw new Error("Pi binding already registered"); + const entry = Object.freeze({ + binding: input.binding, + descriptorDigest: input.descriptorDigest, + modelPolicyHash: input.modelPolicyHash, + provider: input.modelIdentity.provider, + modelId: input.modelIdentity.id, + }); + this.#used.add(key); + this.#active.set(key, entry); + let closed = false; + return Object.freeze({ + close: () => { + if (closed) return false; + closed = true; + return this.#active.get(key) === entry && this.#active.delete(key); + }, + }); + } + + get( + fence: Readonly, + bindingRef: string, + ): BindingEntry | undefined { + if (!exactFence(fence) || !REF.test(bindingRef)) return undefined; + return this.#active.get(`${fenceKey(fence)}\0${bindingRef}`); + } +} + +export interface PiBoundModelExecutorResult { + readonly outcome: Readonly; + readonly transcript: unknown; + readonly providerRequestRef?: string; + readonly providerResponseRef?: string; +} + +export interface PiBoundModelExecutor { + execute(input: Readonly<{ + binding: PiRuntimeBinding; + invocation: unknown; + signal: AbortSignal; + publish(payload: Uint8Array): Promise; + }>): Promise; +} + +function exactPrivatePayload( + value: unknown, +): value is Readonly { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + if (Object.getPrototypeOf(value) !== Object.prototype) return false; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + return ( + keys.length === 3 && + keys.every( + (key) => + typeof key === "string" && + ["version", "identity", "invocation"].includes(key) && + "value" in descriptors[key]! && + descriptors[key]!.enumerable, + ) && + descriptors.version?.value === 1 && + Object.isFrozen(value) + ); +} + +function modelDescriptor( + identity: Readonly, +): AgentModelOperationDescriptorV1 | undefined { + return identity.kind === "model" && + identity.descriptor.kind === "model" && + identity.adapterId === PI_MODEL_AGENT_OPERATION_ADAPTER_ID && + identity.adapterVersion === PI_MODEL_AGENT_OPERATION_ADAPTER_VERSION + ? identity.descriptor + : undefined; +} + +function sameFence(a: Readonly, b: Readonly) { + return fenceKey(a) === fenceKey(b); +} + +function exactLookup( + lookup: Readonly, + identity: Readonly, +): boolean { + return ( + sameFence(lookup.fence, identity.fence) && + lookup.operationId === identity.operationId && + lookup.descriptorDigest === identity.descriptorDigest + ); +} + +function terminal( + status: "failed" | "cancelled", + code: "provider_error" | "cancelled", +): AgentGatewayAdapterResult { + const outcome: AgentOperationOutcomeV1 = { status, code }; + return Object.freeze({ + outcome: Object.freeze(outcome), + transcript: Object.freeze({ kind: "model", status }), + }); +} + +const failed = () => terminal("failed", "provider_error"); +const cancelled = () => terminal("cancelled", "cancelled"); + +function ambiguity(error: unknown, signal: AbortSignal) { + if (signal.aborted || (error instanceof Error && error.name === "AbortError")) + return "cancellation_ambiguous" as const; + const text = error instanceof Error ? `${error.name} ${error.message}` : String(error); + if (/time(?:d?\s*out|out)|deadline/i.test(text)) + return "timeout_ambiguous" as const; + return "disconnect_ambiguous" as const; +} + +/** Pi provider execution has no supported durable query/reconciliation surface. */ +export const PI_MODEL_AGENT_OPERATION_RECONCILER: ExecutingOperationReconciler = + Object.freeze({ + async reconcile() { + return Object.freeze({ + status: "indeterminate" as const, + reason: "reconciliation_unsupported" as const, + }); + }, + }); + +/** Production-unwired adapter over a turn-owned binding and invocation capability. */ +export function createPiModelAgentOperationAdapter( + bindings: PiRuntimeBindingRegistry, + invocations: PiModelInvocationRegistry, + executor: PiBoundModelExecutor, +): AgentGatewayAdapter { + return Object.freeze({ + id: PI_MODEL_AGENT_OPERATION_ADAPTER_ID, + version: PI_MODEL_AGENT_OPERATION_ADAPTER_VERSION, + async execute( + request: Parameters[0], + signal: AbortSignal, + sink?: AgentGatewayLiveEventSink, + ) { + const descriptor = modelDescriptor(request.identity); + if ( + !descriptor || + descriptor.adapterRequestVersion !== PI_MODEL_AGENT_OPERATION_REQUEST_VERSION || + !exactPrivatePayload(request.payload) + ) return failed(); + const payload = request.payload; + const lookup = payload.identity; + if (!exactLookup(lookup, request.identity)) return failed(); + if (signal.aborted) return cancelled(); + if (!sink) return failed(); + const registered = bindings.get(request.identity.fence, lookup.bindingRef); + if ( + !registered || + registered.descriptorDigest !== request.identity.descriptorDigest || + registered.modelPolicyHash !== descriptor.modelPolicyHash || + registered.binding.model.provider !== registered.provider || + registered.binding.model.id !== registered.modelId + ) return failed(); + + let rejectAbort: ((reason: unknown) => void) | undefined; + let abortObserved = false; + const onAbort = () => { + abortObserved = true; + rejectAbort?.(new DOMException("aborted", "AbortError")); + }; + const abort = new Promise((_resolve, reject) => { + rejectAbort = reject; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); + if (abortObserved) { + void abort.catch(() => undefined); + signal.removeEventListener("abort", onAbort); + return cancelled(); + } + + const consumed = invocations.consumeAdapterPayloadExact(lookup, payload); + if (!consumed) { + signal.removeEventListener("abort", onAbort); + return failed(); + } + + let sequence = 0; + let previousDigest: AgentOperationDigest | null = null; + let publishTail: Promise = Promise.resolve(); + let invoked = false; + try { + invoked = true; + const physical = executor.execute(Object.freeze({ + binding: registered.binding, + invocation: consumed.invocation, + signal, + publish: (eventPayload: Uint8Array) => { + const queued = publishTail.then(async () => { + const envelope = encodePiModelEventV1({ + operationId: request.identity.operationId, + eventSeq: sequence, + previousDigest, + payload: eventPayload, + }); + await sink.publish(envelope); + sequence++; + previousDigest = envelope.eventDigest; + }); + publishTail = queued; + return queued; + }, + })); + const result = await Promise.race([physical, abort]); + await Promise.race([publishTail, abort]); + return Object.freeze({ + outcome: result.outcome, + transcript: result.transcript, + ...(result.providerRequestRef === undefined + ? {} + : { providerRequestRef: result.providerRequestRef }), + ...(result.providerResponseRef === undefined + ? {} + : { providerResponseRef: result.providerResponseRef }), + }); + } catch (error) { + if (!invoked) return signal.aborted ? cancelled() : failed(); + throw new AgentGatewayAmbiguousExecutionError(ambiguity(error, signal)); + } finally { + signal.removeEventListener("abort", onAbort); + } + }, + }); +} diff --git a/packages/core/opensession-server/src/server/agent-operation/pi-model-operation.test.ts b/packages/core/opensession-server/src/server/agent-operation/pi-model-operation.test.ts new file mode 100644 index 0000000000..ab28256ed2 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/pi-model-operation.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test"; +import type { AgentOperationDigest } from "@tellahq/opensession-protocol/agent-operation"; +import { + MAX_PI_MODEL_EVENT_PAYLOAD_BYTES, + MAX_PI_MODEL_INVOCATION_BYTES, + PiModelEventChainDecoder, + PiModelInvocationRegistry, + decodePiModelEventV1, + decodePiModelGatewayPayload, + decodePiModelOperationReferenceV1, + encodePiModelEventV1, + hashPiModelInvocationV1, +} from "./pi-model-operation"; + +const digest = (char: string) => `sha256:${char.repeat(64)}` as AgentOperationDigest; +const ref = (char: number) => Buffer.alloc(32, char).toString("base64url"); +const fence = Object.freeze({ + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 2, +}); +const invocation = Object.freeze({ + prompt: "private prompt sentinel", + messages: Object.freeze([Object.freeze({ role: "user", content: "secret sentinel" })]), + tools: Object.freeze([]), + options: Object.freeze({ apiKey: "private credential sentinel" }), +}); + +function input(overrides: Record = {}) { + return { + fence, + operationId: "operation-1", + bindingRef: ref(1), + invocationRef: ref(2), + descriptorDigest: digest("a"), + invocation, + canonicalBytes: new TextEncoder().encode(JSON.stringify(invocation)), + deadlineMs: 2_000, + ...overrides, + } as any; +} + +function lookup(reference: { invocationDigest: AgentOperationDigest }, overrides: Record = {}) { + return { + fence, + operationId: "operation-1", + bindingRef: ref(1), + invocationRef: ref(2), + descriptorDigest: digest("a"), + invocationDigest: reference.invocationDigest, + ...overrides, + } as any; +} + +describe("Pi model invocation reference and registry", () => { + test("every full invocation byte participates in the domain-separated digest", () => { + const original = new Uint8Array([0, 1, 2, 3]); + const expected = hashPiModelInvocationV1(original); + for (let index = 0; index < original.length; index++) { + const changed = original.slice(); + changed[index] ^= 1; + expect(hashPiModelInvocationV1(changed)).not.toBe(expected); + } + expect(() => hashPiModelInvocationV1(new Uint8Array(MAX_PI_MODEL_INVOCATION_BYTES + 1))).toThrow("byte limit"); + }); + + test("peek is non-consuming and consume is exactly once", () => { + const registry = new PiModelInvocationRegistry({ now: () => 1_000 }); + const owner = registry.register(input()); + expect(registry.peekForDecode(lookup(owner.reference))?.invocation).toBe(invocation); + expect(registry.peekForDecode(lookup(owner.reference))?.invocation).toBe(invocation); + expect(registry.consumeExact(lookup(owner.reference))?.invocation).toBe(invocation); + expect(registry.consumeExact(lookup(owner.reference))).toBeUndefined(); + expect(owner.close()).toBe(false); + expect(owner.close()).toBe(false); + }); + + test("tamper, cross-fence, cross-binding, descriptor mismatch, and expiry fail", () => { + let now = 1_000; + const registry = new PiModelInvocationRegistry({ now: () => now }); + const a = registry.register(input()); + expect(registry.peekForDecode(lookup(a.reference, { invocationDigest: digest("b") }))).toBeUndefined(); + expect(registry.peekForDecode(lookup(a.reference, { bindingRef: ref(3) }))).toBeUndefined(); + expect(registry.peekForDecode(lookup(a.reference, { descriptorDigest: digest("b") }))).toBeUndefined(); + expect(registry.peekForDecode(lookup(a.reference, { fence: Object.freeze({ ...fence, generation: 3 }) }))).toBeUndefined(); + now = 2_000; + expect(registry.peekForDecode(lookup(a.reference))).toBeUndefined(); + expect(registry.deleteExpired()).toBe(0); + }); + + test("registration is bounded, cannot replace identity, and owner close is scoped", () => { + const registry = new PiModelInvocationRegistry({ now: () => 1_000, capacity: 2 }); + const owner = registry.register(input()); + expect(() => registry.register(input())).toThrow("already registered"); + const secondOwner = registry.register(input({ invocationRef: ref(4) })); + expect(() => registry.register(input({ invocationRef: ref(5) }))).toThrow("full"); + expect(owner.close()).toBe(true); + expect(() => registry.register(input())).toThrow("already registered"); + expect(() => registry.register(input({ invocationRef: ref(5) }))).toThrow("full"); + expect(owner.close()).toBe(false); + expect(registry.peekForDecode(lookup(secondOwner.reference, { invocationRef: ref(4) }))).toBeDefined(); + }); + + test("strict decoding rejects getters, prototypes, and extra keys without registry access", () => { + const valid = { + version: 1 as const, + bindingRef: ref(1), + invocationRef: ref(2), + invocationDigest: digest("a"), + }; + expect(decodePiModelOperationReferenceV1(valid)).toEqual(valid); + expect(decodePiModelOperationReferenceV1({ ...valid, prompt: "leak" })).toBeUndefined(); + expect(decodePiModelOperationReferenceV1(Object.assign(Object.create({}), valid))).toBeUndefined(); + expect(decodePiModelOperationReferenceV1(Object.assign(Object.create(null), valid))).toBeUndefined(); + let getterReads = 0; + const getter = { ...valid } as any; + Object.defineProperty(getter, "bindingRef", { enumerable: true, get() { getterReads++; return ref(1); } }); + expect(decodePiModelOperationReferenceV1(getter)).toBeUndefined(); + expect(getterReads).toBe(0); + + let accesses = 0; + const registry = new Proxy({ consumeExact() { accesses++; } }, { get(target, key) { accesses++; return (target as any)[key]; } }); + expect(decodePiModelGatewayPayload(registry as any, { + fence, operationId: "operation-1", bindingRef: ref(1), descriptorDigest: digest("a"), + }, getter)).toBeUndefined(); + expect(accesses).toBe(0); + }); + + test("gateway helper returns full canonical bytes, not reference bytes", () => { + const registry = new PiModelInvocationRegistry({ now: () => 1_000 }); + const owner = registry.register(input()); + const decoded = decodePiModelGatewayPayload(registry, { + fence, operationId: "operation-1", bindingRef: ref(1), descriptorDigest: digest("a"), + }, owner.reference); + expect(decoded?.kind).toBe("model"); + const adapterPayload = decoded!.value as any; + expect(adapterPayload.invocation).toBe(invocation); + expect((decoded as any).retainValueIdentity).toBe(true); + expect(Buffer.from(decoded!.canonicalBytes)).toEqual(Buffer.from(input().canonicalBytes)); + expect(Buffer.from(decoded!.canonicalBytes).equals(Buffer.from(JSON.stringify(owner.reference)))).toBe(false); + expect(decodePiModelGatewayPayload(registry, { + fence, operationId: "operation-1", bindingRef: ref(1), descriptorDigest: digest("a"), + }, owner.reference)?.value).toBe(adapterPayload); + expect(registry.consumeExact(lookup(owner.reference))?.invocation).toBe(invocation); + expect(registry.consumeExact(lookup(owner.reference))).toBeUndefined(); + }); + + test("refs and diagnostics contain no invocation or provider material", () => { + const registry = new PiModelInvocationRegistry({ now: () => 1_000 }); + const owner = registry.register(input()); + const visible = JSON.stringify(owner.reference); + for (const secret of ["private prompt", "secret sentinel", "credential", "apiKey", "provider"]) + expect(visible).not.toContain(secret); + for (const bad of [ + input({ bindingRef: "private prompt sentinel" }), + input({ canonicalBytes: new Uint8Array(MAX_PI_MODEL_INVOCATION_BYTES + 1) }), + input({ deadlineMs: 999 }), + ]) { + let message = ""; + try { registry.register(bad); } catch (error) { message = String(error); } + expect(message).not.toContain("private prompt sentinel"); + expect(message).not.toContain("credential sentinel"); + } + }); +}); + +describe("Pi live model event codec", () => { + test("enforces the 48 KiB boundary and canonical Base64URL", () => { + const boundary = new Uint8Array(MAX_PI_MODEL_EVENT_PAYLOAD_BYTES).fill(255); + const envelope = encodePiModelEventV1({ operationId: "operation-1", eventSeq: 0, previousDigest: null, payload: boundary }); + expect(envelope.payload).not.toContain("="); + expect(decodePiModelEventV1(envelope)?.payloadBytes).toEqual(boundary); + expect(() => encodePiModelEventV1({ operationId: "operation-1", eventSeq: 0, previousDigest: null, payload: new Uint8Array(MAX_PI_MODEL_EVENT_PAYLOAD_BYTES + 1) })).toThrow("byte limit"); + expect(decodePiModelEventV1({ ...envelope, payload: `${envelope.payload}=` })).toBeUndefined(); + }); + + test("rejects sequence, predecessor, payload digest, and event digest tampering", () => { + const first = encodePiModelEventV1({ operationId: "operation-1", eventSeq: 0, previousDigest: null, payload: new Uint8Array([1]) }); + const second = encodePiModelEventV1({ operationId: "operation-1", eventSeq: 1, previousDigest: first.eventDigest, payload: new Uint8Array([2]) }); + const chain = new PiModelEventChainDecoder("operation-1"); + expect(chain.decode(second)).toBeUndefined(); + expect(chain.decode(first)).toBeDefined(); + expect(chain.decode({ ...second, previousDigest: digest("c") })).toBeUndefined(); + expect(chain.decode({ ...second, payloadDigest: digest("d") })).toBeUndefined(); + expect(chain.decode({ ...second, eventDigest: digest("e") })).toBeUndefined(); + expect(chain.decode(second)).toBeDefined(); + expect(chain.decode(second)).toBeUndefined(); + }); + + test("envelope carries no raw provider metadata", () => { + const envelope = encodePiModelEventV1({ operationId: "operation-1", eventSeq: 0, previousDigest: null, payload: new TextEncoder().encode("opaque") }); + expect(Object.keys(envelope).sort()).toEqual([ + "eventDigest", "eventSeq", "operationId", "payload", "payloadDigest", "previousDigest", "version", + ]); + for (const key of ["provider", "model", "headers", "configuration", "apiKey", "prompt"]) + expect(JSON.stringify(envelope)).not.toContain(key); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/pi-model-operation.ts b/packages/core/opensession-server/src/server/agent-operation/pi-model-operation.ts new file mode 100644 index 0000000000..8682dc70c3 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/pi-model-operation.ts @@ -0,0 +1,540 @@ +import { createHash } from "node:crypto"; +import type { AgentOperationDigest } from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentTurnFence } from "@tellahq/opensession-protocol/agent-host"; +import type { AgentGatewayDecodedPayload } from "./gateway"; + +export const PI_MODEL_INVOCATION_DIGEST_DOMAIN = + "opensession.pi-model.invocation.v1\0"; +export const PI_MODEL_EVENT_PAYLOAD_DIGEST_DOMAIN = + "opensession.pi-model.event-payload.v1\0"; +export const PI_MODEL_EVENT_DIGEST_DOMAIN = + "opensession.pi-model.event.v1\0"; +export const MAX_PI_MODEL_INVOCATION_BYTES = 1024 * 1024; +export const MAX_PI_MODEL_EVENT_PAYLOAD_BYTES = 48 * 1024; +const DEFAULT_REGISTRY_CAPACITY = 256; +const OPAQUE_REF_BYTES = 32; +const DIGEST = /^sha256:[a-f0-9]{64}$/; +const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; +const encoder = new TextEncoder(); + +export interface PiModelOperationReferenceV1 { + readonly version: 1; + readonly bindingRef: string; + readonly invocationRef: string; + readonly invocationDigest: AgentOperationDigest; +} + +export interface PiModelInvocationIdentity { + readonly fence: Readonly; + readonly operationId: string; + readonly bindingRef: string; + readonly invocationRef: string; + readonly descriptorDigest: AgentOperationDigest; +} + +export interface PiModelInvocationRegistrationInput + extends PiModelInvocationIdentity { + /** An already-private, deeply immutable invocation snapshot. */ + readonly invocation: unknown; + /** Caller-produced canonical encoding of the complete invocation. */ + readonly canonicalBytes: Uint8Array; + readonly deadlineMs: number; +} + +export interface PiModelInvocationRegistration { + readonly reference: Readonly; + /** Sole capability for deleting this registration. Safe to repeat. */ + close(): boolean; +} + +export interface PiModelInvocationRegistryOptions { + readonly capacity?: number; + readonly now?: () => number; +} + +export interface PiModelInvocationLookup extends PiModelInvocationIdentity { + readonly invocationDigest: AgentOperationDigest; +} + +export interface PiModelPrivateAdapterPayloadV1 { + readonly version: 1; + readonly identity: Readonly; + readonly invocation: unknown; +} + +export interface PiModelInvocationSnapshot { + readonly invocation: unknown; + readonly canonicalBytes: Uint8Array; + readonly adapterPayload: Readonly; +} + +interface Entry { + readonly key: string; + readonly invocation: unknown; + readonly canonicalBytes: Uint8Array; + readonly deadlineMs: number; + readonly identity: PiModelInvocationLookup; + readonly adapterPayload: Readonly; +} + +function exactDataRecord( + value: unknown, + keys: readonly string[], +): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + if (Object.getPrototypeOf(value) !== Object.prototype) return false; + const descriptors = Object.getOwnPropertyDescriptors(value); + const ownKeys = Reflect.ownKeys(descriptors); + return ( + ownKeys.length === keys.length && + ownKeys.every( + (key) => + typeof key === "string" && + keys.includes(key) && + "value" in descriptors[key]! && + descriptors[key]!.enumerable, + ) + ); +} + +function validFence(value: unknown): value is Readonly { + if ( + !exactDataRecord(value, ["sessionId", "runId", "turnId", "generation"]) + ) + return false; + return ( + typeof value.sessionId === "string" && ID.test(value.sessionId) && + typeof value.runId === "string" && ID.test(value.runId) && + typeof value.turnId === "string" && ID.test(value.turnId) && + Number.isSafeInteger(value.generation) && + (value.generation as number) >= 0 + ); +} + +function validOpaqueRef(value: unknown): value is string { + if (typeof value !== "string" || value.includes("=")) return false; + if (!/^[A-Za-z0-9_-]+$/.test(value)) return false; + try { + const bytes = Buffer.from(value, "base64url"); + return ( + bytes.byteLength === OPAQUE_REF_BYTES && + bytes.toString("base64url") === value + ); + } catch { + return false; + } +} + +function validDigest(value: unknown): value is AgentOperationDigest { + return typeof value === "string" && DIGEST.test(value); +} + +function validIdentity(value: PiModelInvocationIdentity): boolean { + return ( + validFence(value.fence) && + typeof value.operationId === "string" && ID.test(value.operationId) && + validOpaqueRef(value.bindingRef) && + validOpaqueRef(value.invocationRef) && + validDigest(value.descriptorDigest) + ); +} + +function immutableSnapshot(value: unknown): boolean { + const seen = new Set(); + let count = 0; + const visit = (item: unknown, depth: number): boolean => { + if (++count > 16_384 || depth > 32) return false; + if ( + item === null || + typeof item === "string" || + typeof item === "boolean" || + (typeof item === "number" && Number.isFinite(item)) + ) + return true; + if (!item || typeof item !== "object" || seen.has(item)) return false; + if (!Object.isFrozen(item)) return false; + const prototype = Object.getPrototypeOf(item); + if (prototype !== Object.prototype && prototype !== null && !Array.isArray(item)) + return false; + seen.add(item); + const descriptors = Object.getOwnPropertyDescriptors(item); + if (Reflect.ownKeys(descriptors).some((key) => typeof key !== "string")) return false; + for (const [key, descriptor] of Object.entries(descriptors)) { + if (Array.isArray(item) && key === "length") continue; + if (!("value" in descriptor) || !descriptor.enumerable) return false; + if (!visit(descriptor.value, depth + 1)) return false; + } + return true; + }; + return visit(value, 0); +} + +function fenceKey(fence: Readonly): string { + return JSON.stringify([ + fence.sessionId, + fence.runId, + fence.turnId, + fence.generation, + ]); +} + +function identityKey(identity: PiModelInvocationIdentity): string { + return JSON.stringify([ + fenceKey(identity.fence), + identity.operationId, + identity.bindingRef, + identity.invocationRef, + ]); +} + +function sameIdentity(entry: PiModelInvocationLookup, lookup: PiModelInvocationLookup) { + return ( + entry.operationId === lookup.operationId && + entry.bindingRef === lookup.bindingRef && + entry.invocationRef === lookup.invocationRef && + entry.descriptorDigest === lookup.descriptorDigest && + entry.invocationDigest === lookup.invocationDigest && + fenceKey(entry.fence) === fenceKey(lookup.fence) + ); +} + +function hash(domain: string, bytes: Uint8Array): AgentOperationDigest { + return `sha256:${createHash("sha256").update(domain).update(bytes).digest("hex")}`; +} + +export function hashPiModelInvocationV1( + canonicalBytes: Uint8Array, +): AgentOperationDigest { + if (!(canonicalBytes instanceof Uint8Array)) + throw new TypeError("invalid model invocation bytes"); + if (canonicalBytes.byteLength > MAX_PI_MODEL_INVOCATION_BYTES) + throw new RangeError("model invocation exceeds byte limit"); + return hash(PI_MODEL_INVOCATION_DIGEST_DOMAIN, canonicalBytes); +} + +export function decodePiModelOperationReferenceV1( + value: unknown, +): Readonly | undefined { + if ( + !exactDataRecord(value, [ + "version", + "bindingRef", + "invocationRef", + "invocationDigest", + ]) || + value.version !== 1 || + !validOpaqueRef(value.bindingRef) || + !validOpaqueRef(value.invocationRef) || + !validDigest(value.invocationDigest) + ) + return undefined; + return Object.freeze({ + version: 1, + bindingRef: value.bindingRef, + invocationRef: value.invocationRef, + invocationDigest: value.invocationDigest, + }); +} + +/** Import-inert, bounded store for gateway-private full model invocations. */ +export class PiModelInvocationRegistry { + readonly #active = new Map(); + /** Decode-only tombstones let the gateway authenticate settled duplicate replays. */ + readonly #decodable = new Map(); + readonly #used = new Set(); + readonly #capacity: number; + readonly #now: () => number; + + constructor(options: PiModelInvocationRegistryOptions = {}) { + const capacity = options.capacity ?? DEFAULT_REGISTRY_CAPACITY; + if (!Number.isSafeInteger(capacity) || capacity <= 0) + throw new TypeError("invalid model invocation registry capacity"); + this.#capacity = capacity; + this.#now = options.now ?? Date.now; + } + + register(input: PiModelInvocationRegistrationInput): PiModelInvocationRegistration { + if (!validIdentity(input)) throw new TypeError("invalid model invocation identity"); + if (!Number.isSafeInteger(input.deadlineMs) || input.deadlineMs <= this.#now()) + throw new Error("model invocation deadline has expired"); + if (!immutableSnapshot(input.invocation)) + throw new TypeError("model invocation snapshot must be immutable"); + if (!(input.canonicalBytes instanceof Uint8Array)) + throw new TypeError("invalid model invocation bytes"); + if (input.canonicalBytes.byteLength > MAX_PI_MODEL_INVOCATION_BYTES) + throw new RangeError("model invocation exceeds byte limit"); + const key = identityKey(input); + if (this.#used.has(key)) + throw new Error("model invocation identity was already registered"); + // Tombstones are retained so consumed/closed identities can never be + // reinstalled. Bound total lifetime registrations, not merely live entries. + if (this.#used.size >= this.#capacity) + throw new Error("model invocation registry is full"); + const canonicalBytes = Uint8Array.from(input.canonicalBytes); + const invocationDigest = hashPiModelInvocationV1(canonicalBytes); + const identity = Object.freeze({ + fence: Object.freeze({ ...input.fence }), + operationId: input.operationId, + bindingRef: input.bindingRef, + invocationRef: input.invocationRef, + descriptorDigest: input.descriptorDigest, + invocationDigest, + }); + const adapterPayload = Object.freeze({ + version: 1 as const, + identity, + invocation: input.invocation, + }); + const entry: Entry = Object.freeze({ + key, + invocation: input.invocation, + canonicalBytes, + deadlineMs: input.deadlineMs, + identity, + adapterPayload, + }); + this.#used.add(key); + this.#active.set(key, entry); + this.#decodable.set(key, entry); + const reference = Object.freeze({ + version: 1 as const, + bindingRef: input.bindingRef, + invocationRef: input.invocationRef, + invocationDigest, + }); + let closed = false; + return Object.freeze({ + reference, + close: () => { + if (closed) return false; + closed = true; + if (this.#active.get(key) !== entry) return false; + this.#decodable.delete(key); + return this.#active.delete(key); + }, + }); + } + + /** Deletes entries whose caller-owned absolute deadline has passed. */ + deleteExpired(now = this.#now()): number { + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid expiry time"); + let deleted = 0; + for (const [key, entry] of this.#decodable) { + if (entry.deadlineMs <= now) { + this.#decodable.delete(key); + if (this.#active.delete(key)) deleted++; + } + } + return deleted; + } + + peekForDecode(lookup: PiModelInvocationLookup): PiModelInvocationSnapshot | undefined { + return this.#lookup(lookup, false); + } + + consumeExact(lookup: PiModelInvocationLookup): PiModelInvocationSnapshot | undefined { + return this.#lookup(lookup, true); + } + + /** Consumes only the exact private object emitted by this registry's decoder. */ + consumeAdapterPayloadExact( + lookup: PiModelInvocationLookup, + payload: Readonly, + ): PiModelInvocationSnapshot | undefined { + if (!validIdentity(lookup) || !validDigest(lookup.invocationDigest)) return undefined; + const entry = this.#active.get(identityKey(lookup)); + if (!entry || entry.adapterPayload !== payload) return undefined; + return this.#lookup(lookup, true); + } + + #lookup( + lookup: PiModelInvocationLookup, + consume: boolean, + ): PiModelInvocationSnapshot | undefined { + if (!validIdentity(lookup) || !validDigest(lookup.invocationDigest)) return undefined; + const key = identityKey(lookup); + const entry = consume ? this.#active.get(key) : this.#decodable.get(key); + if (!entry) return undefined; + if (entry.deadlineMs <= this.#now()) { + this.#active.delete(key); + this.#decodable.delete(key); + return undefined; + } + if (!sameIdentity(entry.identity, lookup)) return undefined; + if (consume && !this.#active.delete(key)) return undefined; + return Object.freeze({ + invocation: entry.invocation, + canonicalBytes: Uint8Array.from(entry.canonicalBytes), + adapterPayload: entry.adapterPayload, + }); + } +} + +export interface PiModelGatewayLookupExpectation { + readonly fence: Readonly; + readonly operationId: string; + readonly descriptorDigest: AgentOperationDigest; + readonly bindingRef: string; +} + +/** Strictly decode a host-visible ref without consuming its private invocation. + * Authorization and admission happen after this gateway decode boundary. The + * model adapter consumes the exact registration only after execution begins. */ +export function decodePiModelGatewayPayload( + registry: PiModelInvocationRegistry, + expectation: PiModelGatewayLookupExpectation, + payload: unknown, +): AgentGatewayDecodedPayload | undefined { + const reference = decodePiModelOperationReferenceV1(payload); + if (!reference || !validIdentity({ + ...expectation, + invocationRef: reference.invocationRef, + })) return undefined; + if (reference.bindingRef !== expectation.bindingRef) return undefined; + const snapshot = registry.peekForDecode({ + ...expectation, + invocationRef: reference.invocationRef, + invocationDigest: reference.invocationDigest, + }); + if (!snapshot) return undefined; + return Object.freeze({ + kind: "model" as const, + value: snapshot.adapterPayload, + canonicalBytes: snapshot.canonicalBytes, + retainValueIdentity: true as const, + }); +} + +export interface PiModelEventEnvelopeV1 { + readonly version: 1; + readonly operationId: string; + readonly eventSeq: number; + readonly previousDigest: AgentOperationDigest | null; + readonly payload: string; + readonly payloadDigest: AgentOperationDigest; + readonly eventDigest: AgentOperationDigest; +} + +function lengthPrefix(value: string | Uint8Array): Buffer { + const bytes = typeof value === "string" ? encoder.encode(value) : value; + const prefix = Buffer.allocUnsafe(4); + prefix.writeUInt32BE(bytes.byteLength); + return Buffer.concat([prefix, bytes]); +} + +function eventDigest(input: { + operationId: string; + eventSeq: number; + previousDigest: AgentOperationDigest | null; + payloadDigest: AgentOperationDigest; + payload: Uint8Array; +}): AgentOperationDigest { + const hashValue = createHash("sha256").update(PI_MODEL_EVENT_DIGEST_DOMAIN); + for (const part of [ + input.operationId, + String(input.eventSeq), + input.previousDigest ?? "", + input.payloadDigest, + ]) hashValue.update(lengthPrefix(part)); + hashValue.update(lengthPrefix(input.payload)); + return `sha256:${hashValue.digest("hex")}`; +} + +function decodeCanonicalPayload(value: unknown): Uint8Array | undefined { + if (typeof value !== "string" || value.includes("=") || !/^[A-Za-z0-9_-]*$/.test(value)) + return undefined; + try { + const bytes = Buffer.from(value, "base64url"); + if ( + bytes.byteLength > MAX_PI_MODEL_EVENT_PAYLOAD_BYTES || + bytes.toString("base64url") !== value + ) return undefined; + return Uint8Array.from(bytes); + } catch { + return undefined; + } +} + +export function encodePiModelEventV1(input: { + readonly operationId: string; + readonly eventSeq: number; + readonly previousDigest: AgentOperationDigest | null; + readonly payload: Uint8Array; +}): Readonly { + if (typeof input.operationId !== "string" || !ID.test(input.operationId)) + throw new TypeError("invalid model event operation identity"); + if (!Number.isSafeInteger(input.eventSeq) || input.eventSeq < 0) + throw new TypeError("invalid model event sequence"); + if ((input.eventSeq === 0) !== (input.previousDigest === null) || + (input.previousDigest !== null && !validDigest(input.previousDigest))) + throw new TypeError("invalid model event predecessor"); + if (!(input.payload instanceof Uint8Array) || input.payload.byteLength > MAX_PI_MODEL_EVENT_PAYLOAD_BYTES) + throw new RangeError("model event payload exceeds byte limit"); + const payload = Uint8Array.from(input.payload); + const payloadDigest = hash(PI_MODEL_EVENT_PAYLOAD_DIGEST_DOMAIN, payload); + return Object.freeze({ + version: 1, + operationId: input.operationId, + eventSeq: input.eventSeq, + previousDigest: input.previousDigest, + payload: Buffer.from(payload).toString("base64url"), + payloadDigest, + eventDigest: eventDigest({ ...input, payload, payloadDigest }), + }); +} + +export function decodePiModelEventV1( + value: unknown, +): Readonly<{ envelope: PiModelEventEnvelopeV1; payloadBytes: Uint8Array }> | undefined { + if (!exactDataRecord(value, [ + "version", "operationId", "eventSeq", "previousDigest", "payload", "payloadDigest", "eventDigest", + ]) || value.version !== 1 || typeof value.operationId !== "string" || !ID.test(value.operationId) || + !Number.isSafeInteger(value.eventSeq) || (value.eventSeq as number) < 0 || + ((value.eventSeq === 0) !== (value.previousDigest === null)) || + (value.previousDigest !== null && !validDigest(value.previousDigest)) || + !validDigest(value.payloadDigest) || !validDigest(value.eventDigest)) return undefined; + const payloadBytes = decodeCanonicalPayload(value.payload); + if (!payloadBytes) return undefined; + const payloadDigest = hash(PI_MODEL_EVENT_PAYLOAD_DIGEST_DOMAIN, payloadBytes); + if (payloadDigest !== value.payloadDigest) return undefined; + const expected = eventDigest({ + operationId: value.operationId, + eventSeq: value.eventSeq as number, + previousDigest: value.previousDigest as AgentOperationDigest | null, + payloadDigest, + payload: payloadBytes, + }); + if (expected !== value.eventDigest) return undefined; + const envelope = Object.freeze({ + version: 1 as const, + operationId: value.operationId, + eventSeq: value.eventSeq as number, + previousDigest: value.previousDigest as AgentOperationDigest | null, + payload: value.payload as string, + payloadDigest, + eventDigest: value.eventDigest, + }); + return Object.freeze({ envelope, payloadBytes }); +} + +/** Stateful strict decoder enforcing one operation's sequence and hash chain. */ +export class PiModelEventChainDecoder { + readonly #operationId: string; + #nextSeq = 0; + #previousDigest: AgentOperationDigest | null = null; + + constructor(operationId: string) { + if (!ID.test(operationId)) throw new TypeError("invalid model event operation identity"); + this.#operationId = operationId; + } + + decode(value: unknown): Readonly<{ envelope: PiModelEventEnvelopeV1; payloadBytes: Uint8Array }> | undefined { + const decoded = decodePiModelEventV1(value); + if (!decoded || decoded.envelope.operationId !== this.#operationId || + decoded.envelope.eventSeq !== this.#nextSeq || + decoded.envelope.previousDigest !== this.#previousDigest) return undefined; + this.#nextSeq++; + this.#previousDigest = decoded.envelope.eventDigest; + return decoded; + } +} diff --git a/packages/core/opensession-server/src/server/agent-operation/production-probes.test.ts b/packages/core/opensession-server/src/server/agent-operation/production-probes.test.ts new file mode 100644 index 0000000000..60d2f33397 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/production-probes.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; +import { checkAgentHostReadiness } from "./readiness"; +import { collectAgentHostReadinessObservations } from "./readiness-collector"; +import { + createProductionAgentHostReadinessDependencies, + type ProductionAgentHostReadinessOwners, +} from "./production-probes"; + +const NOW = 2_000_000_000_000; +const digest = (value: string) => `sha256:${value.repeat(64)}`; + +type MutableOwners = { + -readonly [K in keyof ProductionAgentHostReadinessOwners]: ProductionAgentHostReadinessOwners[K] +}; + +function fixture(): MutableOwners { + const peerUids = new Map([[10, 1001], [11, 1002], [12, 1002], [13, 1003]]); + const credentialBytes = (pid: number, uid: number, gid: number) => { + const bytes = new Uint8Array(12); + const view = new DataView(bytes.buffer); + view.setInt32(0, pid, true); view.setUint32(4, uid, true); view.setUint32(8, gid, true); + return bytes; + }; + const reader = { + read(fd: number) { + const uid = peerUids.get(fd); + if (uid === undefined) throw new Error("not an inherited AF_UNIX descriptor"); + return { bytes: credentialBytes(fd + 100, uid, uid), returnedLength: 12, unixDomain: true }; + }, + }; + return { + now: () => NOW, + deadlineMs: 50, + clockStore: { load: () => NOW - 1, retainAtLeast: () => {} }, + serviceUids: { + currentProcessUid: () => 1002, + exactServiceUids: () => ({ gateway: 1001, executor: 1003, sessionKernel: 1004 }), + }, + peerCredentials: { + reader, + descriptors: { + gatewaySeenByHost: 10, + hostSeenByGateway: 11, + hostSeenByExecutor: 12, + executorSeenByHost: 13, + }, + }, + generation: { + activeManifest: () => ({ + manifestDigest: digest("a"), protocolDigest: digest("b"), + releaseDigest: digest("c"), keyringDigest: digest("d"), + digestsMatchManifest: true, activatedAtMs: NOW - 1_000, deadlineMs: NOW + 1_000, + }), + activeSigningKey: () => ({ + verifiedByActiveKeyring: true, notBeforeMs: NOW - 1_000, notAfterMs: NOW + 1_000, + }), + encryptionKeyAvailable: () => true, + }, + hostLedger: { readiness: () => ({ schemaVersion: 1, recoveryComplete: true }) }, + gatewayOperationLedger: { readiness: () => ({ schemaVersion: 2, recoverActiveComplete: true }) }, + sessionKernel: { readiness: () => ({ schemaVersion: 32, cancellationAvailable: true }) }, + route: { externalObservation: () => ({ + routeMode: "agent_host_only", infrastructureFallback: false, + }) }, + host: { readiness: () => ({ active: true, healthy: true, admission: "active" }) }, + registries: { + capacities: () => ({ + gatewayGrants: { size: 1, capacity: 10 }, gatewayOperations: { size: 1, capacity: 10 }, + hostTurns: { size: 1, capacity: 10 }, hostOperations: { size: 1, capacity: 10 }, + hostStreams: { size: 1, capacity: 10 }, + }), + }, + capabilities: { readiness: () => ({ deletion: true, recovery: true, streamAck: true }) }, + }; +} + +async function health(owners: ProductionAgentHostReadinessOwners) { + const dependencies = createProductionAgentHostReadinessDependencies(owners); + return checkAgentHostReadiness(await collectAgentHostReadinessObservations(dependencies)); +} + +describe("production Agent Host readiness probes", () => { + test("accepts exact Linux SO_PEERCRED UIDs from inherited descriptors", async () => { + expect(await health(fixture())).toMatchObject({ ready: true, failingChecks: [] }); + }); + + test("fails closed when the kernel reports the wrong Unix peer", async () => { + const owners = fixture(); + owners.peerCredentials = { + ...owners.peerCredentials, + reader: { read: () => ({ + bytes: (() => { + const bytes = new Uint8Array(12); const view = new DataView(bytes.buffer); + view.setInt32(0, 1, true); view.setUint32(4, 9999, true); view.setUint32(8, 9999, true); + return bytes; + })(), + returnedLength: 12, unixDomain: true, + }) }, + }; + expect((await health(owners)).failingChecks).toEqual(expect.arrayContaining([ + "host_gateway_peer_uid_mismatch", "gateway_host_peer_uid_mismatch", + "host_executor_peer_uid_mismatch", "executor_host_peer_uid_mismatch", + ])); + }); + + test("rejects stale generation and signing-key windows", async () => { + const owners = fixture(); + owners.generation = { + ...owners.generation, + activeManifest: () => ({ + manifestDigest: digest("a"), protocolDigest: digest("b"), releaseDigest: digest("c"), + keyringDigest: digest("d"), digestsMatchManifest: true, + activatedAtMs: NOW - 2_000, deadlineMs: NOW - 1, + }), + activeSigningKey: () => ({ + verifiedByActiveKeyring: true, notBeforeMs: NOW - 2_000, notAfterMs: NOW - 1, + }), + }; + expect((await health(owners)).failingChecks).toEqual(expect.arrayContaining([ + "generation_stale", "signing_public_key_not_current", + ])); + }); + + test("rejects incomplete ledger recovery and incompatible kernel cancellation", async () => { + const owners = fixture(); + owners.hostLedger = { readiness: () => ({ schemaVersion: 1, recoveryComplete: false }) }; + owners.gatewayOperationLedger = { readiness: () => ({ schemaVersion: 2, recoverActiveComplete: false }) }; + owners.sessionKernel = { readiness: () => ({ schemaVersion: 31, cancellationAvailable: false }) }; + expect((await health(owners)).failingChecks).toEqual(expect.arrayContaining([ + "host_ledger_recovery_incomplete", "gateway_operation_recovery_incomplete", + "session_kernel_schema_incompatible", "session_kernel_cancellation_unavailable", + ])); + }); + + test("derives registry bounds and detects literal infrastructure fallback", async () => { + const owners = fixture(); + owners.registries = { capacities: () => ({ + gatewayGrants: { size: 11, capacity: 10 }, gatewayOperations: { size: 1, capacity: 10 }, + hostTurns: { size: 1, capacity: 10 }, hostOperations: { size: 1, capacity: 10 }, + hostStreams: { size: 1, capacity: 10 }, + }) }; + owners.route = { externalObservation: () => ({ routeMode: "agent_host_only", infrastructureFallback: true }) as never }; + expect((await health(owners)).failingChecks).toEqual(expect.arrayContaining([ + "gateway_grant_registry_unbounded", "infrastructure_fallback_enabled", + ])); + }); + + test("is import-inert and redacts owner extras and failures", async () => { + let calls = 0; + const owners = fixture(); + owners.serviceUids.currentProcessUid = () => { calls++; return 1002; }; + const dependencies = createProductionAgentHostReadinessDependencies(owners); + expect(calls).toBe(0); + owners.generation.activeManifest = () => ({ + manifestDigest: digest("a"), protocolDigest: digest("b"), releaseDigest: digest("c"), + keyringDigest: digest("d"), digestsMatchManifest: true, + activatedAtMs: NOW - 1, deadlineMs: NOW + 1, + secret: "provider-payload", path: "/private/keyring", + }) as never; + owners.capabilities.readiness = () => { throw new Error("token /private/config"); }; + const encoded = JSON.stringify(await collectAgentHostReadinessObservations(dependencies)); + expect(calls).toBe(1); + expect(encoded).not.toContain("provider-payload"); + expect(encoded).not.toContain("/private"); + expect(encoded).not.toContain("token"); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/production-probes.ts b/packages/core/opensession-server/src/server/agent-operation/production-probes.ts new file mode 100644 index 0000000000..d74eb83dd1 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/production-probes.ts @@ -0,0 +1,177 @@ +import type { AgentHostReadinessObservations } from "./readiness"; +import type { + AgentHostReadinessClockStore, + AgentHostReadinessCollectorDependencies, +} from "./readiness-collector"; +import { decodeLinuxUcred } from "../security/transport/linux-peer-credentials"; +import type { ProductionSessionKernelReadinessFacade } from "./session-kernel-readiness"; + +type MaybePromise = T | Promise; +type Read = (signal: AbortSignal) => MaybePromise; + +export interface ProductionServiceUidOwner { + currentProcessUid(signal: AbortSignal): MaybePromise; + exactServiceUids(signal: AbortSignal): MaybePromise>; +} + +/** Accepted AF_UNIX descriptors. The owner keeps descriptor lifetime ownership. */ +export interface ProductionPeerCredentialDescriptors { + readonly gatewaySeenByHost: number; + readonly hostSeenByGateway: number; + readonly hostSeenByExecutor: number; + readonly executorSeenByHost: number; +} + +export interface ProductionLinuxPeerCredentialRead { + readonly bytes: Uint8Array; + readonly returnedLength: number; + readonly unixDomain: boolean; +} + +/** Narrow getsockopt(SO_PEERCRED) owner over inherited descriptors. */ +export interface ProductionLinuxPeerCredentialReader { + read(fd: number, signal: AbortSignal): MaybePromise; +} + +export interface ProductionGenerationProofOwner { + activeManifest: Read; + activeSigningKey: Read; + encryptionKeyAvailable: Read; +} + +export interface ProductionLedgerReadinessOwner { + readiness(signal: AbortSignal): MaybePromise; +} + +export type { ProductionSessionKernelReadinessFacade } from "./session-kernel-readiness"; + +export interface ProductionRegistryCapacity { + readonly size: number; + readonly capacity: number; +} + +export interface ProductionRegistryCapacityFacade { + capacities(signal: AbortSignal): MaybePromise>; +} + +export interface ProductionExternalRouteObservation { + readonly routeMode: "legacy" | "agent_host_only"; + readonly infrastructureFallback: false; +} + +/** Evidence read from the deployed router/control plane, not a component feed. */ +export interface ProductionRouteReadinessFacade { + externalObservation(signal: AbortSignal): MaybePromise; +} + +export interface ProductionAgentHostReadinessOwners { + readonly now: () => number; + readonly clockStore: AgentHostReadinessClockStore; + readonly serviceUids: ProductionServiceUidOwner; + readonly peerCredentials: Readonly<{ + reader: ProductionLinuxPeerCredentialReader; + descriptors: ProductionPeerCredentialDescriptors; + }>; + readonly generation: ProductionGenerationProofOwner; + readonly hostLedger: ProductionLedgerReadinessOwner; + readonly gatewayOperationLedger: ProductionLedgerReadinessOwner; + readonly sessionKernel: ProductionSessionKernelReadinessFacade; + readonly route: ProductionRouteReadinessFacade; + readonly host: ProductionLedgerReadinessOwner; + readonly registries: ProductionRegistryCapacityFacade; + readonly capabilities: ProductionLedgerReadinessOwner; + readonly deadlineMs?: number; +} + +function inheritedFd(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) + throw new Error("Invalid inherited Unix socket descriptor"); + return value; +} + +function boundedCapacity(value: ProductionRegistryCapacity): boolean { + return !!value && Number.isSafeInteger(value.size) && value.size >= 0 && + Number.isSafeInteger(value.capacity) && value.capacity > 0 && + value.size <= value.capacity; +} + +/** + * Builds the still-unwired production collector dependencies. It performs no + * work until collection invokes a probe, never owns/closes inherited FDs, and + * returns only the readiness contract's closed proof shapes. + */ +export function createProductionAgentHostReadinessDependencies( + owners: Readonly, +): AgentHostReadinessCollectorDependencies { + const peerUid = async (fd: number, signal: AbortSignal): Promise => { + const proof = await owners.peerCredentials.reader.read(inheritedFd(fd), signal); + if (proof.unixDomain !== true) throw new Error("Peer descriptor is not AF_UNIX"); + const credentials = decodeLinuxUcred(proof.bytes, proof.returnedLength); + if (!Number.isSafeInteger(credentials.pid) || credentials.pid < 1) + throw new Error("Kernel returned malformed peer credentials"); + return credentials.uid; + }; + let routeObservation: Promise | undefined; + const observeRoute = (signal: AbortSignal) => { + if (routeObservation) return routeObservation; + const pending = Promise.resolve(owners.route.externalObservation(signal)); + routeObservation = pending; + void pending.then( + () => { if (routeObservation === pending) routeObservation = undefined; }, + () => { if (routeObservation === pending) routeObservation = undefined; }, + ); + return pending; + }; + + return Object.freeze({ + now: owners.now, + clockStore: owners.clockStore, + ...(owners.deadlineMs === undefined ? {} : { deadlineMs: owners.deadlineMs }), + probes: Object.freeze({ + currentProcessUid: (signal: AbortSignal) => owners.serviceUids.currentProcessUid(signal), + serviceUids: (signal: AbortSignal) => owners.serviceUids.exactServiceUids(signal), + unixPeerUids: async (signal: AbortSignal) => { + const descriptors = owners.peerCredentials.descriptors; + const [gatewaySeenByHost, hostSeenByGateway, hostSeenByExecutor, executorSeenByHost] = + await Promise.all([ + peerUid(descriptors.gatewaySeenByHost, signal), + peerUid(descriptors.hostSeenByGateway, signal), + peerUid(descriptors.hostSeenByExecutor, signal), + peerUid(descriptors.executorSeenByHost, signal), + ]); + return Object.freeze({ gatewaySeenByHost, hostSeenByGateway, hostSeenByExecutor, executorSeenByHost }); + }, + activeGeneration: owners.generation.activeManifest, + signingPublicKey: owners.generation.activeSigningKey, + encryptionKeyAvailable: owners.generation.encryptionKeyAvailable, + hostLedger: (signal: AbortSignal) => owners.hostLedger.readiness(signal), + gatewayOperationLedger: (signal: AbortSignal) => owners.gatewayOperationLedger.readiness(signal), + sessionKernel: (signal: AbortSignal) => owners.sessionKernel.readiness(signal), + routeMode: async (signal: AbortSignal) => (await observeRoute(signal)).routeMode, + host: (signal: AbortSignal) => owners.host.readiness(signal), + boundedRegistries: async (signal: AbortSignal) => { + const capacities = await owners.registries.capacities(signal); + return Object.freeze({ + gatewayGrants: boundedCapacity(capacities.gatewayGrants), + gatewayOperations: boundedCapacity(capacities.gatewayOperations), + hostTurns: boundedCapacity(capacities.hostTurns), + hostOperations: boundedCapacity(capacities.hostOperations), + hostStreams: boundedCapacity(capacities.hostStreams), + }); + }, + infrastructureFallback: async (signal: AbortSignal) => + (await observeRoute(signal)).infrastructureFallback, + capabilities: (signal: AbortSignal) => owners.capabilities.readiness(signal), + }), + }); +} diff --git a/packages/core/opensession-server/src/server/agent-operation/readiness-collector.test.ts b/packages/core/opensession-server/src/server/agent-operation/readiness-collector.test.ts new file mode 100644 index 0000000000..4a82908b55 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/readiness-collector.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from "bun:test"; +import { + collectAgentHostReadinessObservations, + type AgentHostReadinessCollectorDependencies, +} from "./readiness-collector"; +import { checkAgentHostReadiness } from "./readiness"; + +const NOW = 2_000_000_000_000; +const HOUR = 60 * 60 * 1000; +const digest = (character: string) => `sha256:${character.repeat(64)}`; + +type MutableProbes = { + -readonly [Key in keyof AgentHostReadinessCollectorDependencies["probes"]]: + AgentHostReadinessCollectorDependencies["probes"][Key]; +}; +type TestDependencies = { + -readonly [Key in keyof AgentHostReadinessCollectorDependencies]: + Key extends "probes" + ? MutableProbes + : AgentHostReadinessCollectorDependencies[Key]; +} & { retained: number[] }; + +function dependencies(): TestDependencies { + const retained: number[] = []; + return { + retained, + now: () => NOW, + deadlineMs: 50, + clockStore: { + load: () => NOW - 1, + retainAtLeast: (value) => { retained.push(value); }, + }, + probes: { + currentProcessUid: () => 1002, + serviceUids: () => ({ gateway: 1001, executor: 1003, sessionKernel: 1004 }), + unixPeerUids: () => ({ + gatewaySeenByHost: 1001, + hostSeenByGateway: 1002, + hostSeenByExecutor: 1002, + executorSeenByHost: 1003, + }), + activeGeneration: () => ({ + manifestDigest: digest("a"), + protocolDigest: digest("b"), + releaseDigest: digest("c"), + keyringDigest: digest("d"), + digestsMatchManifest: true, + activatedAtMs: NOW - HOUR, + deadlineMs: NOW + 23 * HOUR, + }), + signingPublicKey: () => ({ + verifiedByActiveKeyring: true, + notBeforeMs: NOW - HOUR, + notAfterMs: NOW + HOUR, + }), + encryptionKeyAvailable: () => true, + hostLedger: () => ({ schemaVersion: 1, recoveryComplete: true }), + gatewayOperationLedger: () => ({ schemaVersion: 2, recoverActiveComplete: true }), + sessionKernel: () => ({ schemaVersion: 32, cancellationAvailable: true }), + routeMode: () => "agent_host_only", + host: () => ({ active: true, healthy: true, admission: "active" }), + boundedRegistries: () => ({ + gatewayGrants: true, + gatewayOperations: true, + hostTurns: true, + hostOperations: true, + hostStreams: true, + }), + infrastructureFallback: () => false, + capabilities: () => ({ deletion: true, recovery: true, streamAck: true }), + }, + }; +} + +async function health(deps = dependencies()) { + return checkAgentHostReadiness(await collectAgentHostReadinessObservations(deps)); +} + +describe("Agent Host readiness observation collector", () => { + test("collects the healthy contract and retains the current wall clock", async () => { + const deps = dependencies(); + const observations = await collectAgentHostReadinessObservations(deps); + expect(checkAgentHostReadiness(observations)).toEqual({ + contractVersion: 1, + ready: true, + admission: "allow", + routeMode: "agent_host_only", + failingChecks: [], + capabilities: { deletion: true, recovery: true, streamAck: true }, + }); + expect(observations.serviceUids.host).toBe(1002); + expect(deps.retained).toEqual([NOW]); + }); + + for (const probeName of [ + "currentProcessUid", + "serviceUids", + "unixPeerUids", + "activeGeneration", + "signingPublicKey", + "encryptionKeyAvailable", + "hostLedger", + "gatewayOperationLedger", + "sessionKernel", + "routeMode", + "host", + "boundedRegistries", + "infrastructureFallback", + "capabilities", + ] as const) { + test(`fails closed when ${probeName} errors`, async () => { + const deps = dependencies(); + deps.probes[probeName] = (() => { throw new Error("private /secret/path token"); }) as never; + expect((await health(deps)).ready).toBe(false); + }); + + test(`fails closed when ${probeName} exceeds the shared deadline`, async () => { + const deps = dependencies(); + deps.deadlineMs = 2; + deps.probes[probeName] = (() => new Promise(() => {})) as never; + expect((await health(deps)).ready).toBe(false); + }); + } + + test("fails closed when either monotonic clock store operation fails", async () => { + const loadFailure = dependencies(); + loadFailure.clockStore.load = () => { throw new Error("load failed"); }; + expect((await health(loadFailure)).failingChecks).toContain("clock_rollback"); + + const retainFailure = dependencies(); + retainFailure.clockStore.retainAtLeast = () => { throw new Error("retain failed"); }; + expect((await health(retainFailure)).failingChecks).toContain("clock_rollback"); + }); + + test("detects rollback and never asks the store to lower its retained value", async () => { + const deps = dependencies(); + deps.now = () => NOW - 10; + deps.clockStore.load = () => NOW; + const result = await health(deps); + expect(result.failingChecks).toContain("clock_rollback"); + expect(deps.retained).toEqual([NOW]); + }); + + test("fails wrong peer, generation, keyring, and incomplete recovery proofs", async () => { + const deps = dependencies(); + deps.probes.unixPeerUids = () => ({ + gatewaySeenByHost: 9999, + hostSeenByGateway: 1002, + hostSeenByExecutor: 1002, + executorSeenByHost: 1003, + }); + deps.probes.activeGeneration = () => ({ + manifestDigest: digest("a"), + protocolDigest: digest("b"), + releaseDigest: digest("c"), + keyringDigest: digest("d"), + digestsMatchManifest: false, + activatedAtMs: NOW - HOUR, + deadlineMs: NOW + HOUR, + }); + deps.probes.signingPublicKey = () => ({ + verifiedByActiveKeyring: false, + notBeforeMs: NOW - HOUR, + notAfterMs: NOW + HOUR, + }); + deps.probes.hostLedger = () => ({ schemaVersion: 1, recoveryComplete: false }); + deps.probes.gatewayOperationLedger = () => ({ schemaVersion: 2, recoverActiveComplete: false }); + expect((await health(deps)).failingChecks).toEqual(expect.arrayContaining([ + "host_gateway_peer_uid_mismatch", + "generation_digest_mismatch", + "signing_public_key_not_in_active_keyring", + "host_ledger_recovery_incomplete", + "gateway_operation_recovery_incomplete", + ])); + }); + + test("preserves legacy versus agent_host_only Host requirements", async () => { + const legacy = dependencies(); + legacy.probes.routeMode = () => "legacy"; + legacy.probes.host = () => ({ active: false, healthy: false, admission: "none" }); + legacy.probes.boundedRegistries = () => ({ + gatewayGrants: false, + gatewayOperations: false, + hostTurns: false, + hostOperations: false, + hostStreams: false, + }); + legacy.probes.infrastructureFallback = () => true; + expect((await health(legacy)).ready).toBe(true); + + const hostOnly = dependencies(); + hostOnly.probes.host = legacy.probes.host; + expect((await health(hostOnly)).ready).toBe(false); + }); + + test("requires infrastructureFallback to be the literal false", async () => { + const deps = dependencies(); + deps.probes.infrastructureFallback = () => 0; + expect((await health(deps)).failingChecks).toContain("infrastructure_fallback_enabled"); + }); + + test("redacts extra probe fields and never returns errors, secrets, or paths", async () => { + const deps = dependencies(); + deps.probes.activeGeneration = () => ({ + ...(dependencies().probes.activeGeneration(new AbortController().signal) as object), + privateKey: "super-secret", + manifestPath: "/private/generation.json", + }) as never; + deps.probes.host = () => ({ + active: true, + healthy: true, + admission: "active", + socketPath: "/private/host.sock", + credential: "super-secret", + }) as never; + const encoded = JSON.stringify(await collectAgentHostReadinessObservations(deps)); + expect(encoded).not.toContain("super-secret"); + expect(encoded).not.toContain("/private/"); + expect(encoded).not.toContain("privateKey"); + expect(encoded).not.toContain("socketPath"); + }); + + test("module import is inert until collection is invoked", async () => { + let calls = 0; + const deps = dependencies(); + deps.now = () => { calls++; return NOW; }; + expect(calls).toBe(0); + await collectAgentHostReadinessObservations(deps); + expect(calls).toBe(1); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/readiness-collector.ts b/packages/core/opensession-server/src/server/agent-operation/readiness-collector.ts new file mode 100644 index 0000000000..5ed0313c1e --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/readiness-collector.ts @@ -0,0 +1,236 @@ +import type { AgentHostReadinessObservations } from "./readiness"; + +type MaybePromise = T | Promise; +type Probe = (signal: AbortSignal) => MaybePromise; + +type ServiceUidProofs = Readonly<{ + gateway: number; + executor: number; + sessionKernel: number; +}>; + +export interface AgentHostReadinessClockStore { + /** Returns the greatest wall-clock observation durably retained so far. */ + load(signal: AbortSignal): MaybePromise; + /** Atomically retains at least this value; implementations must never lower it. */ + retainAtLeast(observedAtMs: number, signal: AbortSignal): MaybePromise; +} + +export interface AgentHostReadinessCollectorDependencies { + readonly now: () => number; + readonly clockStore: AgentHostReadinessClockStore; + readonly probes: Readonly<{ + currentProcessUid: Probe; + serviceUids: Probe; + unixPeerUids: Probe; + activeGeneration: Probe; + signingPublicKey: Probe; + encryptionKeyAvailable: Probe; + hostLedger: Probe; + gatewayOperationLedger: Probe; + sessionKernel: Probe; + routeMode: Probe; + host: Probe; + boundedRegistries: Probe; + infrastructureFallback: Probe; + capabilities: Probe; + }>; + /** One deadline shared by all parallel probes. Clamped to 1..5000ms. */ + readonly deadlineMs?: number; +} + +const FAILED = Symbol("agent-host-readiness-probe-failed"); +const MAX_DEADLINE_MS = 5_000; + +/** + * Production-unwired observation collection for the detached Agent Host. + * Every live read is injected. The returned value is a closed, redacted shape + * suitable for checkAgentHostReadiness; probe errors never escape it. + */ +export async function collectAgentHostReadinessObservations( + dependencies: Readonly, +): Promise { + const controller = new AbortController(); + const deadlineMs = boundedDeadline(dependencies.deadlineMs); + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(); + resolve(FAILED); + }, deadlineMs); + }); + const signal = controller.signal; + const run = async (probe: Probe): Promise => { + try { + return await Promise.race([ + Promise.resolve().then(() => probe(signal)), + deadline, + ]); + } catch { + return FAILED; + } + }; + + let nowMs: number; + try { + nowMs = dependencies.now(); + } catch { + nowMs = -1; + } + + const clockProbe: Probe = async (clockSignal) => { + const stored = await dependencies.clockStore.load(clockSignal); + const previous = stored === undefined ? nowMs : stored; + const retained = validTime(previous) + ? validTime(nowMs) + ? Math.max(previous, nowMs) + : previous + : validTime(nowMs) + ? nowMs + : 0; + await dependencies.clockStore.retainAtLeast(retained, clockSignal); + if (!validTime(previous)) throw new Error("invalid retained clock observation"); + return previous; + }; + + const [ + previousObservedAtMs, + hostUid, + serviceUids, + unixPeerUids, + activeGeneration, + signingPublicKey, + encryptionKeyAvailable, + hostLedger, + gatewayOperationLedger, + sessionKernel, + routeMode, + host, + boundedRegistries, + infrastructureFallback, + capabilities, + ] = await Promise.all([ + run(clockProbe), + run(dependencies.probes.currentProcessUid), + run(dependencies.probes.serviceUids), + run(dependencies.probes.unixPeerUids), + run(dependencies.probes.activeGeneration), + run(dependencies.probes.signingPublicKey), + run(dependencies.probes.encryptionKeyAvailable), + run(dependencies.probes.hostLedger), + run(dependencies.probes.gatewayOperationLedger), + run(dependencies.probes.sessionKernel), + run(dependencies.probes.routeMode), + run(dependencies.probes.host), + run(dependencies.probes.boundedRegistries), + run(dependencies.probes.infrastructureFallback), + run(dependencies.probes.capabilities), + ]); + if (timer !== undefined) clearTimeout(timer); + + return { + nowMs, + previousObservedAtMs: + previousObservedAtMs === FAILED ? -1 : number(previousObservedAtMs, -1), + serviceUids: { + gateway: fieldNumber(serviceUids, "gateway", 0), + host: hostUid === FAILED ? 0 : number(hostUid, 0), + executor: fieldNumber(serviceUids, "executor", 0), + sessionKernel: fieldNumber(serviceUids, "sessionKernel", 0), + }, + unixPeerUids: { + gatewaySeenByHost: fieldNumber(unixPeerUids, "gatewaySeenByHost", 0), + hostSeenByGateway: fieldNumber(unixPeerUids, "hostSeenByGateway", 0), + hostSeenByExecutor: fieldNumber(unixPeerUids, "hostSeenByExecutor", 0), + executorSeenByHost: fieldNumber(unixPeerUids, "executorSeenByHost", 0), + }, + activeGeneration: { + manifestDigest: fieldString(activeGeneration, "manifestDigest", ""), + protocolDigest: fieldString(activeGeneration, "protocolDigest", ""), + releaseDigest: fieldString(activeGeneration, "releaseDigest", ""), + keyringDigest: fieldString(activeGeneration, "keyringDigest", ""), + digestsMatchManifest: fieldBoolean(activeGeneration, "digestsMatchManifest", false), + activatedAtMs: fieldNumber(activeGeneration, "activatedAtMs", -1), + deadlineMs: fieldNumber(activeGeneration, "deadlineMs", -1), + }, + signingPublicKey: { + verifiedByActiveKeyring: fieldBoolean(signingPublicKey, "verifiedByActiveKeyring", false), + notBeforeMs: fieldNumber(signingPublicKey, "notBeforeMs", -1), + notAfterMs: fieldNumber(signingPublicKey, "notAfterMs", -1), + }, + encryptionKeyAvailable: + encryptionKeyAvailable === FAILED ? false : encryptionKeyAvailable === true, + hostLedger: { + schemaVersion: fieldNumber(hostLedger, "schemaVersion", 0), + recoveryComplete: fieldBoolean(hostLedger, "recoveryComplete", false), + }, + gatewayOperationLedger: { + schemaVersion: fieldNumber(gatewayOperationLedger, "schemaVersion", 0), + recoverActiveComplete: fieldBoolean(gatewayOperationLedger, "recoverActiveComplete", false), + }, + sessionKernel: { + schemaVersion: fieldNumber(sessionKernel, "schemaVersion", 0), + cancellationAvailable: fieldBoolean(sessionKernel, "cancellationAvailable", false), + }, + routeMode: routeMode === FAILED ? undefined : routeMode, + host: { + active: fieldBoolean(host, "active", false), + healthy: fieldBoolean(host, "healthy", false), + admission: fieldAdmission(host, "admission"), + }, + boundedRegistries: { + gatewayGrants: fieldBoolean(boundedRegistries, "gatewayGrants", false), + gatewayOperations: fieldBoolean(boundedRegistries, "gatewayOperations", false), + hostTurns: fieldBoolean(boundedRegistries, "hostTurns", false), + hostOperations: fieldBoolean(boundedRegistries, "hostOperations", false), + hostStreams: fieldBoolean(boundedRegistries, "hostStreams", false), + }, + infrastructureFallback: + infrastructureFallback === FAILED ? true : infrastructureFallback, + capabilities: { + deletion: fieldBoolean(capabilities, "deletion", false), + recovery: fieldBoolean(capabilities, "recovery", false), + streamAck: fieldBoolean(capabilities, "streamAck", false), + }, + }; +} + +function boundedDeadline(value: number | undefined): number { + if (!Number.isFinite(value)) return 1_000; + return Math.min(MAX_DEADLINE_MS, Math.max(1, Math.trunc(value!))); +} + +function record(value: unknown): Record | undefined { + return value !== FAILED && typeof value === "object" && value !== null + ? value as Record + : undefined; +} + +function number(value: unknown, fallback: number): number { + return typeof value === "number" ? value : fallback; +} + +function fieldNumber(value: unknown, key: string, fallback: number): number { + return number(record(value)?.[key], fallback); +} + +function fieldString(value: unknown, key: string, fallback: string): string { + const candidate = record(value)?.[key]; + return typeof candidate === "string" ? candidate : fallback; +} + +function fieldBoolean(value: unknown, key: string, fallback: boolean): boolean { + const candidate = record(value)?.[key]; + return typeof candidate === "boolean" ? candidate : fallback; +} + +function fieldAdmission(value: unknown, key: string): "active" | "draining_only" | "none" { + const candidate = record(value)?.[key]; + return candidate === "active" || candidate === "draining_only" || candidate === "none" + ? candidate + : "none"; +} + +function validTime(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} diff --git a/packages/core/opensession-server/src/server/agent-operation/readiness.test.ts b/packages/core/opensession-server/src/server/agent-operation/readiness.test.ts new file mode 100644 index 0000000000..9a913e6f58 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/readiness.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "bun:test"; +import { + checkAgentHostReadiness, + type AgentHostReadinessCheckCode, + type AgentHostReadinessObservations, +} from "./readiness"; + +const NOW = 2_000_000_000_000; +const HOUR = 60 * 60 * 1000; +const digest = (character: string) => `sha256:${character.repeat(64)}`; + +function healthy(): AgentHostReadinessObservations { + return { + nowMs: NOW, + previousObservedAtMs: NOW - 1, + serviceUids: { + gateway: 1001, + host: 1002, + executor: 1003, + sessionKernel: 1004, + }, + unixPeerUids: { + gatewaySeenByHost: 1001, + hostSeenByGateway: 1002, + hostSeenByExecutor: 1002, + executorSeenByHost: 1003, + }, + activeGeneration: { + manifestDigest: digest("a"), + protocolDigest: digest("b"), + releaseDigest: digest("c"), + keyringDigest: digest("d"), + digestsMatchManifest: true, + activatedAtMs: NOW - HOUR, + deadlineMs: NOW + 23 * HOUR, + }, + signingPublicKey: { + verifiedByActiveKeyring: true, + notBeforeMs: NOW - HOUR, + notAfterMs: NOW + HOUR, + }, + encryptionKeyAvailable: true, + hostLedger: { schemaVersion: 1, recoveryComplete: true }, + gatewayOperationLedger: { + schemaVersion: 2, + recoverActiveComplete: true, + }, + sessionKernel: { schemaVersion: 32, cancellationAvailable: true }, + routeMode: "agent_host_only", + host: { active: true, healthy: true, admission: "active" }, + boundedRegistries: { + gatewayGrants: true, + gatewayOperations: true, + hostTurns: true, + hostOperations: true, + hostStreams: true, + }, + infrastructureFallback: false, + capabilities: { deletion: true, recovery: true, streamAck: true }, + }; +} + +type MutableObservations = { + -readonly [Key in keyof AgentHostReadinessObservations]: any; +}; + +function changed(change: (fixture: MutableObservations) => void) { + const fixture = structuredClone(healthy()) as MutableObservations; + change(fixture); + return fixture as AgentHostReadinessObservations; +} + +function expectOnlyFailure( + code: AgentHostReadinessCheckCode, + change: (fixture: MutableObservations) => void, +) { + const health = checkAgentHostReadiness(changed(change)); + expect(health.ready).toBe(false); + expect(health.admission).toBe("block"); + expect(health.failingChecks).toEqual([code]); +} + +describe("Agent Host readiness contract", () => { + test("returns the exact bounded healthy fixture", () => { + expect(checkAgentHostReadiness(healthy())).toEqual({ + contractVersion: 1, + ready: true, + admission: "allow", + routeMode: "agent_host_only", + failingChecks: [], + capabilities: { deletion: true, recovery: true, streamAck: true }, + }); + }); + + const cases: Array< + readonly [ + string, + AgentHostReadinessCheckCode, + (fixture: MutableObservations) => void, + ] + > = [ + ["clock rollback", "clock_rollback", (f) => (f.previousObservedAtMs = NOW + 1)], + [ + "root gateway UID", + "gateway_uid_not_distinct_non_root", + (f) => { + f.serviceUids.gateway = 0; + f.unixPeerUids.gatewaySeenByHost = 0; + }, + ], + [ + "root Host UID", + "host_uid_not_distinct_non_root", + (f) => { + f.serviceUids.host = 0; + f.unixPeerUids.hostSeenByGateway = 0; + f.unixPeerUids.hostSeenByExecutor = 0; + }, + ], + [ + "root Executor UID", + "executor_uid_not_distinct_non_root", + (f) => { + f.serviceUids.executor = 0; + f.unixPeerUids.executorSeenByHost = 0; + }, + ], + [ + "root SessionKernel UID", + "session_kernel_uid_not_distinct_non_root", + (f) => (f.serviceUids.sessionKernel = 0), + ], + [ + "duplicate service UID", + "service_uids_not_distinct", + (f) => { + f.serviceUids.executor = f.serviceUids.host; + f.unixPeerUids.executorSeenByHost = f.serviceUids.host; + }, + ], + ["wrong Host peer at gateway", "gateway_host_peer_uid_mismatch", (f) => (f.unixPeerUids.hostSeenByGateway = 9999)], + ["wrong gateway peer at Host", "host_gateway_peer_uid_mismatch", (f) => (f.unixPeerUids.gatewaySeenByHost = 9999)], + ["wrong Executor peer at Host", "host_executor_peer_uid_mismatch", (f) => (f.unixPeerUids.executorSeenByHost = 9999)], + ["wrong Host peer at Executor", "executor_host_peer_uid_mismatch", (f) => (f.unixPeerUids.hostSeenByExecutor = 9999)], + ["invalid manifest digest", "generation_manifest_digest_invalid", (f) => (f.activeGeneration.manifestDigest = "sha256:no")], + ["invalid protocol digest", "generation_protocol_digest_invalid", (f) => (f.activeGeneration.protocolDigest = "sha256:no")], + ["invalid release digest", "generation_release_digest_invalid", (f) => (f.activeGeneration.releaseDigest = "sha256:no")], + ["invalid keyring digest", "generation_keyring_digest_invalid", (f) => (f.activeGeneration.keyringDigest = "sha256:no")], + ["generation digest mismatch", "generation_digest_mismatch", (f) => (f.activeGeneration.digestsMatchManifest = false)], + ["future generation activation", "generation_activation_in_future", (f) => { + f.activeGeneration.activatedAtMs = NOW + HOUR; + f.activeGeneration.deadlineMs = NOW + 2 * HOUR; + }], + ["overlong generation", "generation_deadline_invalid", (f) => (f.activeGeneration.deadlineMs += 1)], + ["stale generation", "generation_stale", (f) => { + f.activeGeneration.activatedAtMs = NOW - HOUR; + f.activeGeneration.deadlineMs = NOW; + }], + ["unverified signing key", "signing_public_key_not_in_active_keyring", (f) => (f.signingPublicKey.verifiedByActiveKeyring = false)], + ["invalid signing key window", "signing_public_key_window_invalid", (f) => (f.signingPublicKey.notAfterMs = f.signingPublicKey.notBeforeMs)], + ["stale signing key", "signing_public_key_not_current", (f) => { + f.signingPublicKey.notBeforeMs = NOW - 2 * HOUR; + f.signingPublicKey.notAfterMs = NOW; + }], + ["missing encryption key", "encryption_key_unavailable", (f) => (f.encryptionKeyAvailable = false)], + ["Host ledger schema mismatch", "host_ledger_schema_incompatible", (f) => (f.hostLedger.schemaVersion = 2)], + ["Host ledger recovery incomplete", "host_ledger_recovery_incomplete", (f) => (f.hostLedger.recoveryComplete = false)], + ["gateway ledger schema mismatch", "gateway_operation_ledger_schema_incompatible", (f) => (f.gatewayOperationLedger.schemaVersion = 1)], + ["recoverActive incomplete", "gateway_operation_recovery_incomplete", (f) => (f.gatewayOperationLedger.recoverActiveComplete = false)], + ["old SessionKernel schema", "session_kernel_schema_incompatible", (f) => (f.sessionKernel.schemaVersion = 31)], + ["cancellation unavailable", "session_kernel_cancellation_unavailable", (f) => (f.sessionKernel.cancellationAvailable = false)], + ["unknown route mode", "route_mode_invalid", (f) => (f.routeMode = "mixed")], + ["inactive Host", "agent_host_inactive", (f) => (f.host.active = false)], + ["unhealthy Host", "agent_host_unhealthy", (f) => (f.host.healthy = false)], + ["draining-only Host", "agent_host_admission_draining_only", (f) => (f.host.admission = "draining_only")], + ["unbounded gateway grants", "gateway_grant_registry_unbounded", (f) => (f.boundedRegistries.gatewayGrants = false)], + ["unbounded gateway operations", "gateway_operation_registry_unbounded", (f) => (f.boundedRegistries.gatewayOperations = false)], + ["unbounded Host turns", "host_turn_registry_unbounded", (f) => (f.boundedRegistries.hostTurns = false)], + ["unbounded Host operations", "host_operation_registry_unbounded", (f) => (f.boundedRegistries.hostOperations = false)], + ["unbounded Host streams", "host_stream_registry_unbounded", (f) => (f.boundedRegistries.hostStreams = false)], + ["infrastructure fallback true", "infrastructure_fallback_enabled", (f) => (f.infrastructureFallback = true)], + ["deletion unavailable", "deletion_capability_unavailable", (f) => (f.capabilities.deletion = false)], + ["recovery unavailable", "recovery_capability_unavailable", (f) => (f.capabilities.recovery = false)], + ["stream ACK unavailable", "stream_ack_capability_unavailable", (f) => (f.capabilities.streamAck = false)], + ]; + + for (const [name, code, change] of cases) { + test(`fails only ${code} for ${name}`, () => expectOnlyFailure(code, change)); + } + + test("legacy mode does not require unwired Host admission state", () => { + const observations = changed((f) => { + f.routeMode = "legacy"; + f.host = { active: false, healthy: false, admission: "none" }; + for (const key of Object.keys(f.boundedRegistries)) + f.boundedRegistries[key] = false; + f.infrastructureFallback = true; + }); + expect(checkAgentHostReadiness(observations)).toMatchObject({ + ready: true, + routeMode: "legacy", + failingChecks: [], + }); + }); + + test("infrastructureFallback must be the boolean false", () => { + expectOnlyFailure( + "infrastructure_fallback_enabled", + (f) => (f.infrastructureFallback = 0), + ); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/readiness.ts b/packages/core/opensession-server/src/server/agent-operation/readiness.ts new file mode 100644 index 0000000000..3932086e34 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/readiness.ts @@ -0,0 +1,299 @@ +const MAX_GENERATION_LIFETIME_MS = 24 * 60 * 60 * 1000; +const DIGEST = /^sha256:[a-f0-9]{64}$/; +const HOST_LEDGER_SCHEMA_VERSION = 1; +const GATEWAY_OPERATION_LEDGER_SCHEMA_VERSION = 2; +const MINIMUM_SESSION_KERNEL_SCHEMA_VERSION = 32; + +export type AgentHostRouteMode = "legacy" | "agent_host_only"; + +export interface AgentHostReadinessObservations { + readonly nowMs: number; + /** Last clock value retained by the observer, including failed checks. */ + readonly previousObservedAtMs: number; + readonly serviceUids: Readonly<{ + gateway: number; + host: number; + executor: number; + sessionKernel: number; + }>; + readonly unixPeerUids: Readonly<{ + gatewaySeenByHost: number; + hostSeenByGateway: number; + hostSeenByExecutor: number; + executorSeenByHost: number; + }>; + readonly activeGeneration: Readonly<{ + manifestDigest: string; + protocolDigest: string; + releaseDigest: string; + keyringDigest: string; + digestsMatchManifest: boolean; + activatedAtMs: number; + deadlineMs: number; + }>; + readonly signingPublicKey: Readonly<{ + verifiedByActiveKeyring: boolean; + notBeforeMs: number; + notAfterMs: number; + }>; + readonly encryptionKeyAvailable: boolean; + readonly hostLedger: Readonly<{ + schemaVersion: number; + recoveryComplete: boolean; + }>; + readonly gatewayOperationLedger: Readonly<{ + schemaVersion: number; + recoverActiveComplete: boolean; + }>; + readonly sessionKernel: Readonly<{ + schemaVersion: number; + cancellationAvailable: boolean; + }>; + readonly routeMode: unknown; + readonly host: Readonly<{ + active: boolean; + healthy: boolean; + admission: "active" | "draining_only" | "none"; + }>; + readonly boundedRegistries: Readonly<{ + gatewayGrants: boolean; + gatewayOperations: boolean; + hostTurns: boolean; + hostOperations: boolean; + hostStreams: boolean; + }>; + readonly infrastructureFallback: unknown; + readonly capabilities: Readonly<{ + deletion: boolean; + recovery: boolean; + streamAck: boolean; + }>; +} + +export type AgentHostReadinessCheckCode = + | "clock_rollback" + | "gateway_uid_not_distinct_non_root" + | "host_uid_not_distinct_non_root" + | "executor_uid_not_distinct_non_root" + | "session_kernel_uid_not_distinct_non_root" + | "service_uids_not_distinct" + | "gateway_host_peer_uid_mismatch" + | "host_gateway_peer_uid_mismatch" + | "host_executor_peer_uid_mismatch" + | "executor_host_peer_uid_mismatch" + | "generation_manifest_digest_invalid" + | "generation_protocol_digest_invalid" + | "generation_release_digest_invalid" + | "generation_keyring_digest_invalid" + | "generation_digest_mismatch" + | "generation_activation_in_future" + | "generation_deadline_invalid" + | "generation_stale" + | "signing_public_key_not_in_active_keyring" + | "signing_public_key_window_invalid" + | "signing_public_key_not_current" + | "encryption_key_unavailable" + | "host_ledger_schema_incompatible" + | "host_ledger_recovery_incomplete" + | "gateway_operation_ledger_schema_incompatible" + | "gateway_operation_recovery_incomplete" + | "session_kernel_schema_incompatible" + | "session_kernel_cancellation_unavailable" + | "route_mode_invalid" + | "agent_host_inactive" + | "agent_host_unhealthy" + | "agent_host_admission_draining_only" + | "gateway_grant_registry_unbounded" + | "gateway_operation_registry_unbounded" + | "host_turn_registry_unbounded" + | "host_operation_registry_unbounded" + | "host_stream_registry_unbounded" + | "infrastructure_fallback_enabled" + | "deletion_capability_unavailable" + | "recovery_capability_unavailable" + | "stream_ack_capability_unavailable"; + +export interface AgentHostReadinessHealth { + readonly contractVersion: 1; + readonly ready: boolean; + readonly admission: "allow" | "block"; + readonly routeMode: AgentHostRouteMode | "invalid"; + /** Fixed-vocabulary, duplicate-free, and capped by the contract's check count. */ + readonly failingChecks: readonly AgentHostReadinessCheckCode[]; + readonly capabilities: Readonly<{ + deletion: boolean; + recovery: boolean; + streamAck: boolean; + }>; +} + +/** + * Pure readiness/doctor policy over already-collected observations. It performs + * no I/O and deliberately returns no observed values, paths, key material, + * policy handles, or registry contents. + */ +export function checkAgentHostReadiness( + observations: Readonly, +): AgentHostReadinessHealth { + const failures: AgentHostReadinessCheckCode[] = []; + const require = (condition: boolean, code: AgentHostReadinessCheckCode) => { + if (!condition) failures.push(code); + }; + const { serviceUids: uids, unixPeerUids: peers } = observations; + const now = observations.nowMs; + const generation = observations.activeGeneration; + const signingKey = observations.signingPublicKey; + const validNow = exactTime(now); + + require( + validNow && + exactTime(observations.previousObservedAtMs) && + now >= observations.previousObservedAtMs, + "clock_rollback", + ); + require(validUid(uids.gateway), "gateway_uid_not_distinct_non_root"); + require(validUid(uids.host), "host_uid_not_distinct_non_root"); + require(validUid(uids.executor), "executor_uid_not_distinct_non_root"); + require( + validUid(uids.sessionKernel), + "session_kernel_uid_not_distinct_non_root", + ); + require( + new Set([uids.gateway, uids.host, uids.executor, uids.sessionKernel]).size === + 4, + "service_uids_not_distinct", + ); + + require(peers.hostSeenByGateway === uids.host, "gateway_host_peer_uid_mismatch"); + require(peers.gatewaySeenByHost === uids.gateway, "host_gateway_peer_uid_mismatch"); + require(peers.executorSeenByHost === uids.executor, "host_executor_peer_uid_mismatch"); + require(peers.hostSeenByExecutor === uids.host, "executor_host_peer_uid_mismatch"); + + require(DIGEST.test(generation.manifestDigest), "generation_manifest_digest_invalid"); + require(DIGEST.test(generation.protocolDigest), "generation_protocol_digest_invalid"); + require(DIGEST.test(generation.releaseDigest), "generation_release_digest_invalid"); + require(DIGEST.test(generation.keyringDigest), "generation_keyring_digest_invalid"); + require(generation.digestsMatchManifest === true, "generation_digest_mismatch"); + require( + validNow && exactTime(generation.activatedAtMs) && generation.activatedAtMs <= now, + "generation_activation_in_future", + ); + const generationDeadlineValid = + exactTime(generation.activatedAtMs) && + exactTime(generation.deadlineMs) && + generation.deadlineMs > generation.activatedAtMs && + generation.deadlineMs - generation.activatedAtMs <= + MAX_GENERATION_LIFETIME_MS; + require(generationDeadlineValid, "generation_deadline_invalid"); + require( + !generationDeadlineValid || (validNow && now < generation.deadlineMs), + "generation_stale", + ); + + require( + signingKey.verifiedByActiveKeyring === true, + "signing_public_key_not_in_active_keyring", + ); + const signingKeyWindowValid = + exactTime(signingKey.notBeforeMs) && + exactTime(signingKey.notAfterMs) && + signingKey.notAfterMs > signingKey.notBeforeMs; + require(signingKeyWindowValid, "signing_public_key_window_invalid"); + require( + !signingKeyWindowValid || + (validNow && signingKey.notBeforeMs <= now && now < signingKey.notAfterMs), + "signing_public_key_not_current", + ); + require(observations.encryptionKeyAvailable === true, "encryption_key_unavailable"); + + require( + observations.hostLedger.schemaVersion === HOST_LEDGER_SCHEMA_VERSION, + "host_ledger_schema_incompatible", + ); + require(observations.hostLedger.recoveryComplete === true, "host_ledger_recovery_incomplete"); + require( + observations.gatewayOperationLedger.schemaVersion === + GATEWAY_OPERATION_LEDGER_SCHEMA_VERSION, + "gateway_operation_ledger_schema_incompatible", + ); + require( + observations.gatewayOperationLedger.recoverActiveComplete === true, + "gateway_operation_recovery_incomplete", + ); + require( + Number.isSafeInteger(observations.sessionKernel.schemaVersion) && + observations.sessionKernel.schemaVersion >= + MINIMUM_SESSION_KERNEL_SCHEMA_VERSION, + "session_kernel_schema_incompatible", + ); + require( + observations.sessionKernel.cancellationAvailable === true, + "session_kernel_cancellation_unavailable", + ); + + const routeMode = + observations.routeMode === "legacy" || + observations.routeMode === "agent_host_only" + ? observations.routeMode + : "invalid"; + require(routeMode !== "invalid", "route_mode_invalid"); + if (routeMode === "agent_host_only") { + require(observations.host.active === true, "agent_host_inactive"); + require(observations.host.healthy === true, "agent_host_unhealthy"); + require( + observations.host.admission === "active", + "agent_host_admission_draining_only", + ); + require( + observations.boundedRegistries.gatewayGrants === true, + "gateway_grant_registry_unbounded", + ); + require( + observations.boundedRegistries.gatewayOperations === true, + "gateway_operation_registry_unbounded", + ); + require( + observations.boundedRegistries.hostTurns === true, + "host_turn_registry_unbounded", + ); + require( + observations.boundedRegistries.hostOperations === true, + "host_operation_registry_unbounded", + ); + require( + observations.boundedRegistries.hostStreams === true, + "host_stream_registry_unbounded", + ); + require( + observations.infrastructureFallback === false, + "infrastructure_fallback_enabled", + ); + } + + const capabilities = Object.freeze({ + deletion: observations.capabilities.deletion === true, + recovery: observations.capabilities.recovery === true, + streamAck: observations.capabilities.streamAck === true, + }); + require(capabilities.deletion, "deletion_capability_unavailable"); + require(capabilities.recovery, "recovery_capability_unavailable"); + require(capabilities.streamAck, "stream_ack_capability_unavailable"); + + const ready = failures.length === 0; + return Object.freeze({ + contractVersion: 1, + ready, + admission: ready ? "allow" : "block", + routeMode, + failingChecks: Object.freeze(failures), + capabilities, + }); +} + +function validUid(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +function exactTime(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} diff --git a/packages/core/opensession-server/src/server/agent-operation/service.test.ts b/packages/core/opensession-server/src/server/agent-operation/service.test.ts new file mode 100644 index 0000000000..edc2b5adfb --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/service.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + hashAgentModelPayloadV1, + hashAgentOperationDescriptorV1, + type AgentOperationDigest, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentHostSupervisionAuthorityV2 } from "@tellahq/opensession-protocol/agent-host"; +import { + AgentGatewayGrantRegistry, + encodeAgentGatewayPolicyHandle, +} from "./grants"; +import { AgentGatewayAmbiguousExecutionError } from "./gateway"; +import { AgentOperationService, type AgentOperationPlan } from "./service"; +import { SQLiteAgentOperationLedger } from "./sqlite-ledger"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +const digest = (c: string) => `sha256:${c.repeat(64)}` as AgentOperationDigest; +const envelope = { + version: 1, algorithm: "Ed25519", domain: "opensession.agent-host.supervision.v2", + authorityBytes: "AQ", signature: Buffer.alloc(64).toString("base64url"), +} as const; +const payloadBytes = new TextEncoder().encode('{"prompt":"safe"}'); +const descriptor = { + version: 1, kind: "model", stepId: "step-1", + transcript: { throughChangeSeq: 2, entryIds: ["entry-1"], digest: digest("c") }, + modelPolicyHash: digest("d"), adapterRequestVersion: "v1", +} as const; +const fence = { sessionId: "session-1", runId: "run-1", turnId: "turn-1", generation: 1 } as const; +const authority: AgentHostSupervisionAuthorityV2 = { + version: 2, fence, planHash: digest("a"), hostId: "host-000000000001", + hostGeneration: 1, hostIncarnation: "incarnation-0001", supervisorEpoch: 1, + kernelServiceEpoch: "kernel-epoch-0001", hostChallenge: "challenge-00000001", + audience: "opensession-agent-host", purpose: "agent-host-supervision", + issuedAtMs: 1, expiresAtMs: 1_000_000, nonce: "nonce-000000000001", + keyId: "key-0000000000001", +}; + +type Mode = "success" | "wait" | "unknown" | "ambiguous" | "cancel"; +async function fixture(options: { dbPath?: string; mode?: Mode; recoveryGate?: Promise } = {}) { + const root = options.dbPath ? undefined : mkdtempSync(join(tmpdir(), "agent-service-")); + if (root) roots.push(root); + const dbPath = options.dbPath ?? join(root!, "ledger.sqlite"); + const ledger = new SQLiteAgentOperationLedger({ dbPath }); + let now = 10; + let currentAuthorityHash = digest("b"); + let grantsIssued = 0, physical = 0, admits = 0, terminals = 0; + let releasePhysical!: () => void; + const physicalGate = new Promise((resolve) => { releasePhysical = resolve; }); + const grants = new AgentGatewayGrantRegistry({ + now: () => now, + entropy: () => `${String(++grantsIssued).padStart(43, "x")}`, + }); + const descriptorDigest = await hashAgentOperationDescriptorV1(descriptor); + const payloadDigest = await hashAgentModelPayloadV1(payloadBytes); + const order: string[] = []; + const service = new AgentOperationService({ + grants, + gateway: { + ledger, + now: () => Date.now(), + admission: { + async admit() { admits++; order.push("admit"); return { accepted: true }; }, + async settle() { terminals++; order.push("actor-terminal"); }, + async indeterminate() { terminals++; order.push("actor-indeterminate"); }, + }, + adapterFor: () => ({ + id: "adapter-1", version: "1.0", + async execute(_request, signal, sink) { + physical++; order.push("physical"); + if (options.mode === "wait") await physicalGate; + if (options.mode === "unknown") throw new Error("provider vanished"); + if (options.mode === "ambiguous") + throw new AgentGatewayAmbiguousExecutionError("disconnect_ambiguous"); + if (options.mode === "cancel") { + await new Promise((resolve) => { + const done = () => resolve(); + signal.addEventListener("abort", done, { once: true }); + if (signal.aborted) done(); + }); + return { outcome: { status: "cancelled" as const, code: "cancelled" }, transcript: {} }; + } + await sink?.publish({ delta: "one" }); + return { outcome: { status: "succeeded" as const, outputDigest: digest("e") }, transcript: {} }; + }, + }), + decodePayload: (kind, payload) => payload && kind === "model" + ? { kind, value: payload, canonicalBytes: payloadBytes } + : undefined, + appendTerminal: async (_identity, result) => { + order.push("append"); + const code = result.outcome.status === "cancelled" ? "cancelled" : "ok"; + const refs = [{ appendId: `append-${code}`, entryIds: ["entry-terminal"], firstSeq: 3, lastSeq: 3, throughChangeSeq: 3, requestDigest: digest("1") }]; + return { refs, kernelTerminal: { outputDigest: digest("e"), outcomeCode: code, transcriptRefs: refs, pendingToolUseEntryIds: [] } }; + }, + appendIndeterminateNotice: async (record, appendId) => { + await options.recoveryGate; + const refs = [{ appendId, entryIds: ["entry-indeterminate"], firstSeq: 3, lastSeq: 3, throughChangeSeq: 3, requestDigest: digest("2") }]; + return { outputDigest: digest("f"), outcomeCode: record.terminalReservation?.reason ?? "reconciliation_unsupported", transcriptRefs: refs, pendingToolUseEntryIds: [] }; + }, + }, + verifySupervision: async () => ({ authority, authorityHash: currentAuthorityHash }), + authorizedReceiptReader: (query) => ledger.queryAuthorized(query), + cancellation: { + async request(identity) { + order.push("cancel-persist"); + const record = await ledger.getExact(identity); + return record && (record.receipt.state === "prepared" || record.receipt.state === "executing") + ? "requested" : "too_late"; + }, + }, + closeTimeoutMs: 1, + scheduleTimeout: (callback) => { let active = true; queueMicrotask(() => { if (active) callback(); }); return () => { active = false; }; }, + }); + const plan: AgentOperationPlan = { + operationId: "operation-1", fence, kind: "model", descriptor, descriptorDigest, + payload: { prompt: "safe" }, canonicalPayloadBytes: payloadBytes, + transcriptAnchor: descriptor.transcript, adapterId: "adapter-1", adapterVersion: "1.0", + deadlineMs: 500, policyHandle: encodeAgentGatewayPolicyHandle("policy00000000001"), + }; + const dispatch = { operationId: plan.operationId, fence, kind: plan.kind, descriptorDigest, + supervisionEnvelope: envelope, descriptor, deadlineMs: plan.deadlineMs } as const; + const query = { operationId: plan.operationId, fence, kind: plan.kind, descriptorDigest, + supervisionEnvelope: envelope, payloadDigest, descriptor, afterStreamSeq: 0, recovery: true } as const; + return { service, ledger, plan, dispatch, query, grants, + counts: () => ({ grantsIssued, physical, admits, terminals }), order, + releasePhysical, dbPath, payloadDigest, + setAuthorityHash: (value: AgentOperationDigest) => { currentAuthorityHash = value; }, + }; +} +async function consumeOne(result: { chunks?: AsyncIterable | Iterable }) { + const iterator = (result.chunks as AsyncIterable)[Symbol.asyncIterator](); + const value = await iterator.next(); + return { iterator, text: new TextDecoder().decode(value.value) }; +} + +describe("AgentOperationService integration", () => { + test("dispatches exactly once across repeated requests and returns executing before stream ACK", async () => { + const f = await fixture(); + await f.service.start(); + await f.service.registerPlan(f.plan); + const first = await f.service.dispatchOperation(f.dispatch, new AbortController().signal); + expect(first.receipt.state).toBe("executing"); + const streamed = await consumeOne(first); + expect(streamed.text).toContain('"delta":"one"'); + expect(f.counts()).toMatchObject({ grantsIssued: 1, physical: 1, admits: 1 }); + const repeatedPromise = f.service.dispatchOperation(f.dispatch, new AbortController().signal); + await f.service.acknowledgeOperationStream({ ...f.dispatch, throughStreamSeq: 1 }); + const repeated = await repeatedPromise; + expect(["executing", "settled"]).toContain(repeated.receipt.state); + await new Promise((resolve) => setImmediate(resolve)); + const settled = await f.service.queryOperation({ ...f.query, afterStreamSeq: 1 }, new AbortController().signal); + expect(settled.receipt.state).toBe("settled"); + expect(f.counts()).toMatchObject({ grantsIssued: 1, physical: 1, admits: 1, terminals: 1 }); + expect(f.service.healthSnapshot().activeOperations).toBe(0); + await f.service.close(); + }); + + test("publication is ACK-gated before append and actor terminal", async () => { + const f = await fixture(); + await f.service.start(); await f.service.registerPlan(f.plan); + const receipt = await f.service.dispatchOperation(f.dispatch, new AbortController().signal); + await consumeOne(receipt); + expect(f.order).toEqual(["admit", "physical"]); + await f.service.acknowledgeOperationStream({ ...f.dispatch, throughStreamSeq: 1 }); + await new Promise((resolve) => setImmediate(resolve)); + await f.service.queryOperation({ ...f.query, afterStreamSeq: 1 }, new AbortController().signal); + expect(f.order).toEqual(["admit", "physical", "append", "actor-terminal"]); + await f.service.close(); + }); + + test("settled receipt survives restart with a fresh registry and no bearer grant", async () => { + const first = await fixture(); + await first.service.start(); await first.service.registerPlan(first.plan); + const result = await first.service.dispatchOperation(first.dispatch, new AbortController().signal); + await consumeOne(result); + await first.service.acknowledgeOperationStream({ ...first.dispatch, throughStreamSeq: 1 }); + await first.service.queryOperation({ ...first.query, afterStreamSeq: 1 }, new AbortController().signal); + await first.service.close(); + + const second = await fixture({ dbPath: first.dbPath }); + await second.service.start(); await second.service.registerPlan(second.plan); + const queried = await second.service.queryOperation(second.query, new AbortController().signal); + expect(queried.receipt.state).toBe("settled"); + expect(second.grants.size).toBe(0); + expect(second.counts().physical).toBe(0); + await second.service.close(); + }); + + test("wrong authority, payload digest, or descriptor gives zero new physical evidence", async () => { + const f = await fixture(); + await f.service.start(); await f.service.registerPlan(f.plan); + await expect(f.service.queryOperation({ ...f.query, payloadDigest: digest("9") }, new AbortController().signal)).rejects.toThrow("payload digest"); + const altered = { ...descriptor, stepId: "different" }; + await expect(f.service.queryOperation({ ...f.query, descriptor: altered }, new AbortController().signal)).rejects.toThrow("descriptor"); + expect(f.counts()).toEqual({ grantsIssued: 0, physical: 0, admits: 0, terminals: 0 }); + + const running = await f.service.dispatchOperation(f.dispatch, new AbortController().signal); + await consumeOne(running); + await f.service.acknowledgeOperationStream({ ...f.dispatch, throughStreamSeq: 1 }); + await new Promise((resolve) => setImmediate(resolve)); + f.setAuthorityHash(digest("9")); + await expect(f.service.queryOperation({ ...f.query, afterStreamSeq: 1 }, new AbortController().signal)).rejects.toThrow("operation not found"); + expect(f.counts().physical).toBe(1); + f.setAuthorityHash(digest("b")); + expect((await f.service.queryOperation({ ...f.query, afterStreamSeq: 1 }, new AbortController().signal)).receipt.state).toBe("settled"); + await f.service.close(); + }); + + test("unknown execution error is observed as executing then recovered indeterminate", async () => { + const first = await fixture({ mode: "unknown" }); + await first.service.start(); await first.service.registerPlan(first.plan); + const executing = await first.service.dispatchOperation(first.dispatch, new AbortController().signal); + expect(executing.receipt.state).toBe("executing"); + await Promise.resolve(); + await first.service.close(); + const second = await fixture({ dbPath: first.dbPath }); + await second.service.start(); await second.service.registerPlan(second.plan); + const recovered = await second.service.queryOperation(second.query, new AbortController().signal); + expect(recovered.receipt.state).toBe("indeterminate"); + await second.service.close(); + }); + + test("persists cancellation before abort and returns the truthful cancelled terminal", async () => { + const f = await fixture({ mode: "cancel" }); + await f.service.start(); await f.service.registerPlan(f.plan); + const executing = await f.service.dispatchOperation(f.dispatch, new AbortController().signal); + expect(executing.receipt.state).toBe("executing"); + const cancelled = await f.service.cancelOperation({ ...f.dispatch, cancelId: "cancel-1", reason: "user" }, new AbortController().signal); + expect(cancelled.disposition).toBe("cancelled"); + expect(cancelled.receipt.state).toBe("settled"); + expect(f.order.indexOf("cancel-persist")).toBeLessThan(f.order.indexOf("append")); + expect(f.service.healthSnapshot().activeOperations).toBe(0); + await f.service.close(); + }); + + test("prephysical cancellation is explicit and terminal cancellation is too late", async () => { + const f = await fixture(); + await f.service.start(); await f.service.registerPlan(f.plan); + await expect(f.service.cancelOperation({ ...f.dispatch, cancelId: "cancel-pre", reason: "user" }, new AbortController().signal)).rejects.toThrow("operation not found"); + const running = await f.service.dispatchOperation(f.dispatch, new AbortController().signal); + await consumeOne(running); + await f.service.acknowledgeOperationStream({ ...f.dispatch, throughStreamSeq: 1 }); + await new Promise((resolve) => setImmediate(resolve)); + const late = await f.service.cancelOperation({ ...f.dispatch, cancelId: "cancel-late", reason: "user" }, new AbortController().signal); + expect(late.disposition).toBe("too_late"); + expect(late.receipt.state).toBe("settled"); + await f.service.close(); + }); + + test("startup recovery blocks readiness and does not hold actor admission during physical wait", async () => { + const seed = await fixture({ mode: "unknown" }); + await seed.service.start(); await seed.service.registerPlan(seed.plan); + await seed.service.dispatchOperation(seed.dispatch, new AbortController().signal); + await Promise.resolve(); await seed.service.close(); + let releaseRecovery!: () => void; + const recoveryGate = new Promise((resolve) => { releaseRecovery = resolve; }); + const recovering = await fixture({ dbPath: seed.dbPath, recoveryGate }); + const starting = recovering.service.start(); + await Promise.resolve(); + await expect(recovering.service.registerPlan(recovering.plan)).rejects.toThrow("not ready"); + releaseRecovery(); await starting; await recovering.service.close(); + + const waiting = await fixture({ mode: "wait" }); + await waiting.service.start(); await waiting.service.registerPlan(waiting.plan); + const receipt = await waiting.service.dispatchOperation(waiting.dispatch, new AbortController().signal); + expect(receipt.receipt.state).toBe("executing"); + expect(waiting.counts().admits).toBe(1); + waiting.releasePhysical(); await Promise.resolve(); + await waiting.service.close(); + + const bounded = await fixture({ mode: "wait" }); + await bounded.service.start(); await bounded.service.registerPlan(bounded.plan); + await bounded.service.dispatchOperation(bounded.dispatch, new AbortController().signal); + await expect(bounded.service.close()).resolves.toBeUndefined(); + }); + + test("bounds plans and bytes, rejects getters and Proxies, and preserves explicit immutable capability identity", async () => { + const f = await fixture(); + await f.service.start(); + const getter = { ...f.plan } as any; + Object.defineProperty(getter, "payload", { enumerable: true, get() { throw new Error("getter invoked"); } }); + await expect(f.service.registerPlan(getter)).rejects.toThrow("invalid operation plan"); + await expect(f.service.registerPlan(new Proxy(f.plan, {}))).rejects.toThrow("invalid operation plan"); + await expect(f.service.registerPlan({ ...f.plan, canonicalPayloadBytes: new Uint8Array(1024 * 1024 + 1) })).rejects.toThrow("canonical payload"); + const capability = Object.freeze({ token: Object.freeze({ ref: "private" }) }); + await f.service.registerPlan({ ...f.plan, payload: capability, retainPayloadIdentity: true }); + await expect(f.service.registerPlan({ ...f.plan, operationId: "operation-2", payload: { mutable: true }, retainPayloadIdentity: true })).rejects.toThrow("retained model capability"); + await f.service.deleteSession(fence.sessionId); + expect(f.service.healthSnapshot().activeOperations).toBe(0); + await f.service.close(); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/service.ts b/packages/core/opensession-server/src/server/agent-operation/service.ts new file mode 100644 index 0000000000..08bff9bed4 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/service.ts @@ -0,0 +1,621 @@ +import { + hashAgentMcpPayloadV1, + hashAgentModelPayloadV1, + hashAgentOperationDescriptorV1, + type AgentOperationDescriptorV1, + type AgentOperationDigest, + type AgentOperationReceiptV1, + type AgentTranscriptAnchorV1, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { AgentTurnFence } from "@tellahq/opensession-protocol/agent-host"; +import type { SignedAgentHostSupervisionEnvelopeV1 } from "@tellahq/opensession-protocol/agent-host-supervision"; +import { types as utilTypes } from "node:util"; +import type { + AgentHostCancelIntent, + AgentHostCancelResult, + AgentHostDispatchIntent, + AgentHostOperationResult, + AgentHostOperationStreamAckIntent, + AgentHostQueryIntent, + AgentHostQueryResult, +} from "../agent-host-client"; +import { + AgentOperationGateway, + type AgentOperationGatewayOptions, + type VerifiedAgentSupervision, +} from "./gateway"; +import type { + AgentGatewayPolicyHandle, + AgentGatewayGrantRegistry, +} from "./grants"; +import type { AgentOperationAuthorizedQuery } from "./authorized-query"; +import type { AgentOperationIdentity, AgentOperationRecord } from "./ledger"; +import { + AgentOperationStreamJournal, + AgentOperationStreamRecoveryRequiredError, +} from "./stream-journal"; + +const DEFAULT_MAX_PLANS = 1_024; +const DEFAULT_MAX_CANONICAL_PAYLOAD_BYTES = 1024 * 1024; +const PLAN_KEYS = [ + "operationId", "fence", "kind", "descriptor", "descriptorDigest", "payload", + "canonicalPayloadBytes", "transcriptAnchor", "toolUseEntryId", "adapterId", + "adapterVersion", "deadlineMs", "policyHandle", "retainPayloadIdentity", +] as const; + +export interface AgentOperationPlan { + readonly operationId: string; + readonly fence: Readonly; + readonly kind: "model" | "mcp"; + readonly descriptor: AgentOperationDescriptorV1; + readonly descriptorDigest: AgentOperationDigest; + readonly payload: unknown; + readonly canonicalPayloadBytes: Uint8Array; + readonly transcriptAnchor: Readonly; + readonly toolUseEntryId?: string; + readonly adapterId: string; + readonly adapterVersion: string; + readonly deadlineMs: number; + readonly policyHandle: AgentGatewayPolicyHandle; + /** Only for a deeply immutable, gateway-private model capability whose identity is significant. */ + readonly retainPayloadIdentity?: true; +} +export interface AgentOperationCancellationFacade { + request( + identity: AgentOperationIdentity, + cancelId: string, + reason: AgentHostCancelIntent["reason"], + ): Promise<"requested" | "too_late">; +} +export interface AgentOperationServiceOptions { + readonly grants: AgentGatewayGrantRegistry; + readonly gateway: Omit< + AgentOperationGatewayOptions, + "grants" | "beginLiveExecution" | "verifySupervision" + >; + readonly verifySupervision: ( + envelope: SignedAgentHostSupervisionEnvelopeV1, + intent: Readonly<{ + operationId: string; + fence: AgentTurnFence; + kind: "model" | "mcp"; + descriptorDigest: AgentOperationDigest; + }>, + ) => Promise; + readonly authorizedReceiptReader: ( + query: Readonly, + ) => Promise; + readonly cancellation: AgentOperationCancellationFacade; + readonly closeOwners?: readonly (() => void | Promise)[]; + readonly closeTimeoutMs?: number; + readonly maxPlans?: number; + readonly maxCanonicalPayloadBytes?: number; + /** Runtime-owned timeout injection. Construction and module import schedule nothing. */ + readonly scheduleTimeout?: (callback: () => void, delayMs: number) => () => void; +} +type StoredPlan = AgentOperationPlan & { payloadDigest: AgentOperationDigest }; +type Entry = { + plan: StoredPlan; + identity?: AgentOperationIdentity; + journal?: AgentOperationStreamJournal; + controller?: AbortController; + task?: Promise; + dispatchStarted: boolean; + terminal?: AgentOperationRecord; + started?: Promise; + resolveStarted?: (record: AgentOperationRecord) => void; + rejectStarted?: (error: unknown) => void; + removeAbortListener?: () => void; +}; + +/** Boot-owned, import-inert coordinator. Construction performs no I/O. */ +export class AgentOperationService { + readonly #options: AgentOperationServiceOptions; + readonly #entries = new Map(); + readonly #gateway: AgentOperationGateway; + readonly #maxPlans: number; + readonly #maxCanonicalPayloadBytes: number; + #registering = 0; + #ready = false; + #recovering = false; + #startTask?: Promise; + #failed = false; + #closing = false; + + constructor(options: AgentOperationServiceOptions) { + this.#options = options; + this.#maxPlans = positive(options.maxPlans ?? DEFAULT_MAX_PLANS, "plan capacity"); + this.#maxCanonicalPayloadBytes = positive( + options.maxCanonicalPayloadBytes ?? DEFAULT_MAX_CANONICAL_PAYLOAD_BYTES, + "canonical payload byte limit", + ); + this.#gateway = new AgentOperationGateway({ + ...options.gateway, + grants: options.grants, + verifySupervision: (envelope, request) => options.verifySupervision(envelope, request), + beginLiveExecution: async (record) => { + const entry = this.#entries.get(key(record.fence, record.operationId)); + if (!entry) throw new Error("operation plan unavailable"); + const journal = (entry.journal ??= new AgentOperationStreamJournal()); + entry.identity = record; + entry.resolveStarted?.(record); + return journal; + }, + }); + } + + start(): Promise { + if (this.#ready) return Promise.resolve(); + if (this.#closing) + return Promise.reject(new Error("agent operation service is closing")); + if (this.#startTask) return this.#startTask; + this.#recovering = true; + const task = this.#gateway.recoverActive().then(() => { + this.#ready = true; + }, (error) => { + this.#failed = true; + throw error; + }).finally(() => { + this.#recovering = false; + }); + this.#startTask = task; + return task; + } + + async registerPlan(input: Readonly): Promise { + this.#admit(); + if (this.#entries.size + this.#registering >= this.#maxPlans) + throw new Error("agent operation plan registry is full"); + this.#registering++; + try { + const plan = snapshotPlan(input, this.#maxCanonicalPayloadBytes); + const descriptorDigest = await hashAgentOperationDescriptorV1(plan.descriptor); + if (descriptorDigest !== plan.descriptorDigest || plan.kind !== plan.descriptor.kind) + throw new Error("operation plan digest mismatch"); + const payloadDigest = await payloadHash(plan.kind, plan.canonicalPayloadBytes); + const stored = Object.freeze({ ...plan, payloadDigest }) as StoredPlan; + const id = key(plan.fence, plan.operationId); + if (this.#entries.has(id)) throw new Error("operation plan already registered"); + this.#entries.set(id, { plan: stored, dispatchStarted: false }); + } finally { + this.#registering--; + } + } + + dispatchOperation = async ( + intent: Readonly, + signal: AbortSignal, + ): Promise => { + this.#admit(); + const entry = this.#exact(intent); + const verified = await this.#verified(intent); + if (intent.deadlineMs !== entry.plan.deadlineMs) + throw new Error("operation deadline mismatch"); + if (await hashAgentOperationDescriptorV1(intent.descriptor) !== entry.plan.descriptorDigest) + throw new Error("operation descriptor mismatch"); + + const repeated = entry.dispatchStarted; + if (!repeated) this.#beginDispatch(entry, intent, verified, signal); + const record = repeated + ? await this.#readExisting(entry, verified) + : entry.terminal ?? await Promise.race([entry.started!, entry.task!]); + entry.identity = record; + return { + receipt: record.receipt, + ...(entry.journal + ? { chunks: entry.journal.replay(entry.journal.acknowledgedThrough) } + : {}), + }; + }; + + queryOperation = async ( + intent: Readonly, + _signal: AbortSignal, + ): Promise => { + this.#admit(); + const entry = this.#exact(intent); + const verified = await this.#verified(intent); + if (intent.payloadDigest !== undefined && intent.payloadDigest !== entry.plan.payloadDigest) + throw new Error("operation payload digest mismatch"); + if ( + intent.descriptor !== undefined && + await hashAgentOperationDescriptorV1(intent.descriptor) !== entry.plan.descriptorDigest + ) throw new Error("operation descriptor mismatch"); + entry.identity ??= identityFrom(entry.plan, verified); + const record = await this.#options.authorizedReceiptReader( + authorizedQuery(entry.plan, verified, intent.recovery, intent.payloadDigest), + ); + if (!record) throw new Error("operation not found"); + entry.terminal = terminal(record) ? record : entry.terminal; + return { + receipt: record.receipt, + fromStreamSeq: intent.afterStreamSeq + 1, + ...(entry.journal ? { chunks: entry.journal.replay(intent.afterStreamSeq) } : {}), + }; + }; + + cancelOperation = async ( + intent: Readonly, + _signal: AbortSignal, + ): Promise => { + this.#admit(); + const entry = this.#exact(intent); + const verified = await this.#verified(intent); + entry.identity ??= identityFrom(entry.plan, verified); + + // Schema 32 cancellation requires an existing admitted operation. A plan alone + // is deliberately not represented as a synthetic receipt. + const receiptQuery = authorizedQuery( + entry.plan, + verified, + false, + entry.plan.payloadDigest, + ); + const before = await this.#options.authorizedReceiptReader(receiptQuery); + if (!before) throw new Error("operation not found"); + const durable = await this.#options.cancellation.request( + entry.identity, + intent.cancelId, + intent.reason, + ); + if (durable === "requested") entry.controller?.abort(); + + let record = before; + if (durable === "requested" && entry.task) { + try { record = await entry.task; } + catch { + record = (await this.#options.authorizedReceiptReader(receiptQuery)) ?? before; + } + } else { + record = (await this.#options.authorizedReceiptReader(receiptQuery)) ?? before; + } + if (!terminal(record)) + throw new Error("operation cancellation has no terminal receipt"); + entry.terminal = record; + return { disposition: cancellationDisposition(durable, record.receipt), receipt: record.receipt }; + }; + + acknowledgeOperationStream = async ( + intent: Readonly, + ): Promise => { + this.#admit(); + const entry = this.#exact(intent); + if (!entry.journal) throw new AgentOperationStreamRecoveryRequiredError(); + entry.journal.acknowledge(intent.throughStreamSeq); + }; + + healthSnapshot() { + let replayBytes = 0, streams = 0, active = 0; + for (const entry of this.#entries.values()) { + if (entry.dispatchStarted && !entry.terminal) active++; + if (entry.journal) { streams++; replayBytes += entry.journal.bytes; } + } + return Object.freeze({ + ready: this.#ready, + recovering: this.#recovering, + failed: this.#failed, + activeOperations: active, + activeStreams: streams, + replayBytes, + infrastructureFallback: false, + }); + } + + async deleteSession(sessionId: string): Promise { + const removed: Entry[] = []; + for (const [id, entry] of this.#entries) { + if (entry.plan.fence.sessionId !== sessionId) continue; + this.#entries.delete(id); + removed.push(entry); + } + for (const entry of removed) { + entry.removeAbortListener?.(); + entry.controller?.abort(); + await entry.journal?.fail(); + } + this.#options.grants.revokeSession(sessionId); + return removed.length; + } + + async close(): Promise { + if (this.#closing) return; + this.#closing = true; + this.#ready = false; + for (const entry of this.#entries.values()) { + entry.removeAbortListener?.(); + entry.controller?.abort(); + await entry.journal?.fail(); + } + const tasks = [...this.#entries.values()].flatMap((entry) => + entry.task ? [entry.task.catch(() => undefined)] : [], + ); + const timeoutMs = nonnegative(this.#options.closeTimeoutMs ?? 5_000, "close timeout"); + let cancelTimeout = () => {}; + const timeout = new Promise((resolve) => { + cancelTimeout = (this.#options.scheduleTimeout ?? defaultScheduleTimeout)(resolve, timeoutMs); + }); + await Promise.race([Promise.all(tasks).then(() => undefined), timeout]); + cancelTimeout(); + for (const close of this.#options.closeOwners ?? []) await close(); + this.#options.grants.clear(); + this.#entries.clear(); + } + + async #readExisting( + entry: Entry, + verified: VerifiedAgentSupervision, + ): Promise { + const record = await this.#options.authorizedReceiptReader( + authorizedQuery(entry.plan, verified, false, entry.plan.payloadDigest), + ); + if (!record) throw new Error("operation not found"); + if (terminal(record)) entry.terminal = record; + return record; + } + + #beginDispatch( + entry: Entry, + intent: Readonly, + verified: VerifiedAgentSupervision, + signal: AbortSignal, + ) { + entry.dispatchStarted = true; + entry.identity = identityFrom(entry.plan, verified); + entry.controller = new AbortController(); + entry.started = new Promise((resolve, reject) => { + entry.resolveStarted = resolve; + entry.rejectStarted = reject; + }); + const onAbort = () => entry.controller?.abort(); + const removeAbortListener = () => { + signal.removeEventListener("abort", onAbort); + if (entry.removeAbortListener === removeAbortListener) + entry.removeAbortListener = undefined; + }; + entry.removeAbortListener = removeAbortListener; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + try { + const grant = this.#options.grants.issue({ + operationId: entry.plan.operationId, + kind: entry.plan.kind, + fence: entry.plan.fence, + planHash: verified.authority.planHash as AgentOperationDigest, + authorityHash: verified.authorityHash, + supervisorEpoch: verified.authority.supervisorEpoch, + hostId: verified.authority.hostId, + hostGeneration: verified.authority.hostGeneration, + hostIncarnation: verified.authority.hostIncarnation, + descriptorDigest: entry.plan.descriptorDigest, + payloadDigest: entry.plan.payloadDigest, + transcriptAnchor: entry.plan.transcriptAnchor, + ...(entry.plan.toolUseEntryId ? { toolUseEntryId: entry.plan.toolUseEntryId } : {}), + adapterId: entry.plan.adapterId, + adapterVersion: entry.plan.adapterVersion, + deadlineMs: entry.plan.deadlineMs, + authorityExpiresAtMs: verified.authority.expiresAtMs, + policyHandle: entry.plan.policyHandle, + }); + entry.task = this.#gateway.dispatch({ + version: 1, + operationId: entry.plan.operationId, + kind: entry.plan.kind, + fence: entry.plan.fence, + supervisionEnvelope: intent.supervisionEnvelope, + dispatchGrant: grant, + descriptor: entry.plan.descriptor, + descriptorDigest: entry.plan.descriptorDigest, + }, entry.plan.payload, entry.controller.signal); + void entry.task.then((record) => { + entry.identity = record; + if (terminal(record)) entry.terminal = record; + entry.resolveStarted?.(record); + }, (error) => entry.rejectStarted?.(error)).finally(removeAbortListener); + } catch (error) { + removeAbortListener(); + entry.rejectStarted?.(error); + entry.task = Promise.reject(error); + void entry.task.catch(() => undefined); + } + } + + #admit() { + if (!this.#ready || this.#closing || this.#failed) + throw new Error("agent operation service is not ready"); + } + #exact(intent: { operationId: string; fence: AgentTurnFence; kind: string; descriptorDigest: string }) { + const entry = this.#entries.get(key(intent.fence, intent.operationId)); + if (!entry || entry.plan.kind !== intent.kind || + entry.plan.descriptorDigest !== intent.descriptorDigest || + !sameFence(entry.plan.fence, intent.fence)) + throw new Error("operation plan mismatch"); + return entry; + } + async #verified(intent: AgentHostDispatchIntent | AgentHostQueryIntent | AgentHostCancelIntent) { + const verified = await this.#options.verifySupervision(intent.supervisionEnvelope, intent); + if (!verified) throw new Error("invalid supervision"); + return verified; + } +} + +function key(fence: Readonly, operationId: string) { + return `${fence.sessionId}\0${fence.runId}\0${fence.turnId}\0${fence.generation}\0${operationId}`; +} +function sameFence(a: Readonly, b: Readonly) { + return a.sessionId === b.sessionId && a.runId === b.runId && + a.turnId === b.turnId && a.generation === b.generation; +} +function payloadHash(kind: "model" | "mcp", bytes: Uint8Array) { + return kind === "model" ? hashAgentModelPayloadV1(bytes) : hashAgentMcpPayloadV1(bytes); +} + +function snapshotPlan(input: Readonly, maxBytes: number): AgentOperationPlan { + const values = exactDataValues(input, PLAN_KEYS, "operation plan", ["toolUseEntryId", "retainPayloadIdentity"]); + const bytes = values.canonicalPayloadBytes; + if (!(bytes instanceof Uint8Array) || utilTypes.isProxy(bytes) || bytes.byteLength > maxBytes) + throw new TypeError("invalid canonical payload"); + // structuredClone is also the fail-closed Proxy check for all public plan material. + const fence = immutableClone(values.fence, "operation fence") as Readonly; + const descriptor = immutableClone(values.descriptor, "operation descriptor") as AgentOperationDescriptorV1; + const anchor = immutableClone(values.transcriptAnchor, "transcript anchor") as Readonly; + let payload: unknown; + if (values.retainPayloadIdentity === true) { + if (values.kind !== "model" || !deeplyImmutableData(values.payload)) + throw new TypeError("invalid retained model capability"); + try { structuredClone(values.payload); } catch { throw new TypeError("invalid retained model capability"); } + payload = values.payload; + } else { + payload = immutableClone(values.payload, "operation payload"); + } + return Object.freeze({ + operationId: values.operationId as string, + fence, + kind: values.kind as "model" | "mcp", + descriptor, + descriptorDigest: values.descriptorDigest as AgentOperationDigest, + payload, + canonicalPayloadBytes: Uint8Array.from(bytes), + transcriptAnchor: anchor, + ...(values.toolUseEntryId === undefined ? {} : { toolUseEntryId: values.toolUseEntryId as string }), + adapterId: values.adapterId as string, + adapterVersion: values.adapterVersion as string, + deadlineMs: values.deadlineMs as number, + policyHandle: values.policyHandle as AgentGatewayPolicyHandle, + ...(values.retainPayloadIdentity === true ? { retainPayloadIdentity: true as const } : {}), + }); +} + +function exactDataValues( + value: unknown, + allowed: readonly string[], + name: string, + optional: readonly string[] = [], +): Record { + if (!value || typeof value !== "object" || utilTypes.isProxy(value) || Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) + throw new TypeError(`invalid ${name}`); + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if (keys.some((key) => typeof key !== "string" || !allowed.includes(key)) || + allowed.some((key) => !optional.includes(key) && !descriptors[key])) + throw new TypeError(`invalid ${name}`); + const result: Record = Object.create(null); + for (const key of keys as string[]) { + const descriptor = descriptors[key]!; + if (!("value" in descriptor) || !descriptor.enumerable) + throw new TypeError(`invalid ${name}`); + result[key] = descriptor.value; + } + return result; +} +function immutableClone(value: unknown, name: string): unknown { + if (!safeData(value)) throw new TypeError(`invalid ${name}`); + try { return deepFreeze(structuredClone(value)); } + catch { throw new TypeError(`invalid ${name}`); } +} +function safeData(value: unknown, requireFrozen = false, seen = new Set(), depth = 0): boolean { + if (depth > 32) return false; + if (value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (!value || typeof value !== "object" || utilTypes.isProxy(value) || seen.has(value) || (requireFrozen && !Object.isFrozen(value))) return false; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null && !Array.isArray(value)) return false; + seen.add(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if (keys.some((key) => typeof key !== "string")) return false; + if (Array.isArray(value) && keys.length !== value.length + 1) return false; + for (const key of keys as string[]) { + if (Array.isArray(value) && key === "length") continue; + const descriptor = descriptors[key]!; + if (!("value" in descriptor) || !descriptor.enumerable || descriptor.value === undefined || + !safeData(descriptor.value, requireFrozen, seen, depth + 1)) return false; + } + seen.delete(value); + return true; +} +function deeplyImmutableData(value: unknown) { return safeData(value, true); } +function deepFreeze(value: unknown): unknown { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value))) + if ("value" in descriptor) deepFreeze(descriptor.value); + return Object.freeze(value); +} +function terminal(record: AgentOperationRecord) { + return record.receipt.state === "settled" || record.receipt.state === "indeterminate"; +} +function cancellationDisposition( + durable: "requested" | "too_late", + receipt: AgentOperationReceiptV1, +): AgentHostCancelResult["disposition"] { + if (durable === "too_late") return "too_late"; + if (receipt.state === "indeterminate") return "indeterminate"; + return receipt.outcome?.status === "cancelled" ? "cancelled" : "too_late"; +} +function authorizedQuery( + plan: StoredPlan, + verified: VerifiedAgentSupervision, + recovery: boolean, + payloadDigest: AgentOperationDigest | undefined, +): AgentOperationAuthorizedQuery { + if (!recovery && payloadDigest === undefined) + throw new Error("exact operation query requires payload digest"); + const authority = Object.freeze({ + planHash: verified.authority.planHash as AgentOperationDigest, + authorityHash: verified.authorityHash, + supervisorEpoch: verified.authority.supervisorEpoch, + hostId: verified.authority.hostId, + hostGeneration: verified.authority.hostGeneration, + hostIncarnation: verified.authority.hostIncarnation, + }); + const common = { + operationId: plan.operationId, + kind: plan.kind, + fence: plan.fence, + descriptorDigest: plan.descriptorDigest, + authority, + }; + return recovery + ? Object.freeze({ + ...common, + mode: "recovery" as const, + ...(payloadDigest === undefined ? {} : { payloadDigest }), + }) + : Object.freeze({ + ...common, + mode: "exact" as const, + payloadDigest: payloadDigest!, + }); +} +function identityFrom(plan: StoredPlan, verified: VerifiedAgentSupervision): AgentOperationIdentity { + return { + operationId: plan.operationId, + kind: plan.kind, + fence: plan.fence, + planHash: verified.authority.planHash as AgentOperationDigest, + authorityHash: verified.authorityHash, + supervisorEpoch: verified.authority.supervisorEpoch, + hostId: verified.authority.hostId, + hostGeneration: verified.authority.hostGeneration, + hostIncarnation: verified.authority.hostIncarnation, + transcriptAnchor: plan.transcriptAnchor, + ...(plan.toolUseEntryId ? { toolUseEntryId: plan.toolUseEntryId } : {}), + descriptor: plan.descriptor, + descriptorDigest: plan.descriptorDigest, + payloadDigest: plan.payloadDigest, + adapterId: plan.adapterId, + adapterVersion: plan.adapterVersion, + }; +} +function positive(value: number, name: string) { + if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`invalid ${name}`); + return value; +} +function nonnegative(value: number, name: string) { + if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`invalid ${name}`); + return value; +} +function defaultScheduleTimeout(callback: () => void, delayMs: number) { + const handle = setTimeout(callback, delayMs); + return () => clearTimeout(handle); +} diff --git a/packages/core/opensession-server/src/server/agent-operation/session-kernel-readiness.test.ts b/packages/core/opensession-server/src/server/agent-operation/session-kernel-readiness.test.ts new file mode 100644 index 0000000000..cc6eec8c72 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/session-kernel-readiness.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { + createSessionKernelReadinessFacade, + type SessionKernelReadinessOwners, +} from "./session-kernel-readiness"; +import type { ProductionSessionKernelReadinessFacade } from "./production-probes"; + +const metric = () => ({ + turnsCompleted: 0, + queueWaitMsTotal: 0, + busyMsTotal: 0, + timeouts: 0, + restarts: 0, + rejectedFull: 0, + kernelStoreCacheMisses: 0, + kernelStoreCacheEvictions: 0, + transcriptStoreCacheMisses: 0, + transcriptStoreCacheEvictions: 0, + sqliteBusy: 0, +}); + +function lane(index: number, ready = true) { + return { + index, + ready, + restarting: false, + queued: 0, + executing: 0, + ...metric(), + }; +} + +type MutableOwners = { + -readonly [K in keyof SessionKernelReadinessOwners]: SessionKernelReadinessOwners[K]; +}; + +function fixture(sessionLaneCount = 2): MutableOwners { + return { + actorReady: () => ({ + ready: true, + actorVersion: 32, + transportVersion: 1, + workers: { ready: sessionLaneCount, capacity: sessionLaneCount }, + lanes: [lane(0), ...Array.from({ length: sessionLaneCount }, (_, i) => lane(i + 1))], + }), + gatewayStats: () => ({ schemaVersion: 32 }), + cancellation: () => ({ schemaVersion: 32, durableCancellation: true }), + actorTranscripts: () => ({ + placement: "actor", + migrationComplete: true, + pendingMigrations: 0, + }), + deadlineMs: 50, + }; +} + +async function readiness(owners: SessionKernelReadinessOwners) { + const facade = createSessionKernelReadinessFacade(owners); + const productionFacade: ProductionSessionKernelReadinessFacade = facade; + void productionFacade; + return facade.readiness(new AbortController().signal); +} + +describe("production SessionKernel readiness facade", () => { + test("scales with the reported lane population and keeps load/restart signals diagnostic", async () => { + const owners = fixture(257); + owners.actorReady = () => { + const lanes = [lane(0), ...Array.from({ length: 257 }, (_, i) => lane(i + 1))]; + lanes[19] = { ...lanes[19]!, queued: 9, rejectedFull: 4 }; + lanes[211] = { ...lanes[211]!, restarting: true, restarts: 3, ready: false }; + return { + ready: true, + actorVersion: 32, + transportVersion: 1, + workers: { ready: 256, capacity: 257 }, + lanes, + }; + }; + + expect(await readiness(owners)).toEqual({ + schemaVersion: 32, + cancellationAvailable: true, + diagnostics: ["lane_saturation_observed", "lane_restart_observed"], + }); + }); + + test("fails closed on exact actor, transport, storage, and cancellation mismatches", async () => { + for (const mutate of [ + (owners: MutableOwners) => { + owners.actorReady = () => ({ + ready: true, + actorVersion: 33, + transportVersion: 1, + workers: { ready: 1, capacity: 1 }, + lanes: [lane(0), lane(1)], + }); + }, + (owners: MutableOwners) => { + owners.actorReady = () => ({ + ready: true, + actorVersion: 32, + transportVersion: 2, + workers: { ready: 1, capacity: 1 }, + lanes: [lane(0), lane(1)], + }); + }, + (owners: MutableOwners) => { + owners.gatewayStats = () => ({ schemaVersion: 33 }); + }, + (owners: MutableOwners) => { + owners.cancellation = () => ({ schemaVersion: 32, durableCancellation: false }); + }, + ]) { + const owners = fixture(1); + mutate(owners); + expect((await readiness(owners)).schemaVersion).toBe(0); + } + }); + + test("requires at least one ready session worker and matching ready lane", async () => { + const owners = fixture(1); + owners.actorReady = () => ({ + ready: false, + actorVersion: 32, + transportVersion: 1, + workers: { ready: 0, capacity: 3 }, + lanes: [lane(0), lane(1, false), lane(2, false), lane(3, false)], + }); + expect(await readiness(owners)).toMatchObject({ + schemaVersion: 0, + diagnostics: ["no_ready_lane"], + }); + }); + + test("requires complete actor transcript placement migration without enumerating paths", async () => { + const owners = fixture(); + owners.actorTranscripts = () => ({ + placement: "actor", + migrationComplete: false, + pendingMigrations: 2_000_000, + }); + expect(await readiness(owners)).toMatchObject({ + schemaVersion: 0, + diagnostics: ["actor_transcript_migration_incomplete"], + }); + }); + + test("shares a bounded deadline, aborts owners, and fails closed", async () => { + let aborted = false; + const owners = fixture(); + owners.deadlineMs = 1; + owners.gatewayStats = (signal) => new Promise((resolve) => { + signal.addEventListener("abort", () => { + aborted = true; + resolve({ schemaVersion: 32 }); + }, { once: true }); + }); + const result = await readiness(owners); + expect(aborted).toBe(true); + expect(result).toMatchObject({ + schemaVersion: 0, + diagnostics: ["gateway_stats_unavailable"], + }); + }); + + test("uses exact non-invoking decoders and returns only redacted vocabulary", async () => { + let getterCalls = 0; + const owners = fixture(); + owners.actorReady = () => { + const value = { + ready: true, + actorVersion: 32, + transportVersion: 1, + workers: { ready: 1, capacity: 1 }, + lanes: [lane(0), lane(1)], + secretPath: "/private/session-kernel.sqlite", + }; + Object.defineProperty(value, "actorVersion", { + enumerable: true, + get() { getterCalls++; return 32; }, + }); + return value; + }; + owners.gatewayStats = () => { + throw new Error("token=secret /private/kernel.sqlite"); + }; + const encoded = JSON.stringify(await readiness(owners)); + expect(getterCalls).toBe(0); + expect(encoded).toContain("actor_readiness_unavailable"); + expect(encoded).toContain("gateway_stats_unavailable"); + expect(encoded).not.toContain("secret"); + expect(encoded).not.toContain("private"); + expect(encoded).not.toContain("sqlite"); + }); + + test("is inert until readiness is invoked", async () => { + let calls = 0; + const owners = fixture(); + owners.actorReady = () => { calls++; return { + ready: true, + actorVersion: 32, + transportVersion: 1, + workers: { ready: 1, capacity: 1 }, + lanes: [lane(0), lane(1)], + }; }; + const facade = createSessionKernelReadinessFacade(owners); + expect(calls).toBe(0); + await facade.readiness(new AbortController().signal); + expect(calls).toBe(1); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/session-kernel-readiness.ts b/packages/core/opensession-server/src/server/agent-operation/session-kernel-readiness.ts new file mode 100644 index 0000000000..dda7b788cc --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/session-kernel-readiness.ts @@ -0,0 +1,314 @@ +import { + SESSION_KERNEL_ACTOR_VERSION, + SESSION_KERNEL_TRANSPORT_VERSION, +} from "../session-kernel/actor-protocol"; +import { SESSION_KERNEL_SCHEMA_VERSION } from "../session-kernel/store"; + +type MaybePromise = T | Promise; +type BoundedRead = (signal: AbortSignal) => MaybePromise; + +export interface ProductionSessionKernelReadinessFacade { + readiness(signal: AbortSignal): MaybePromise>; +} + +export type SessionKernelSchema32CancellationCapability = Readonly<{ + schemaVersion: typeof SESSION_KERNEL_SCHEMA_VERSION; + durableCancellation: true; +}>; + +export interface SessionKernelReadinessOwners { + /** Bounded projection of the SessionKernel GET /ready response. */ + readonly actorReady: BoundedRead; + /** Bounded gateway-owned projection of SessionKernel stats. */ + readonly gatewayStats: BoundedRead; + /** Typed proof that the exact schema-32 cancellation path is available. */ + readonly cancellation: BoundedRead; + /** Bounded placement summary. It must not contain session ids or paths. */ + readonly actorTranscripts: BoundedRead; + readonly deadlineMs?: number; +} + +export type SessionKernelReadinessDiagnostic = + | "actor_readiness_unavailable" + | "actor_version_mismatch" + | "transport_version_mismatch" + | "no_ready_lane" + | "lane_saturation_observed" + | "lane_restart_observed" + | "gateway_stats_unavailable" + | "gateway_schema_mismatch" + | "cancellation_proof_unavailable" + | "actor_transcript_proof_unavailable" + | "actor_transcript_migration_incomplete"; + +export interface SessionKernelReadinessResult { + readonly schemaVersion: number; + readonly cancellationAvailable: boolean; + /** Fixed, duplicate-free vocabulary. No owner values are reflected here. */ + readonly diagnostics: readonly SessionKernelReadinessDiagnostic[]; +} + +type Lane = Readonly<{ + index: number; + ready: boolean; + restarting: boolean; + queued: number; + executing: number; + turnsCompleted: number; + queueWaitMsTotal: number; + busyMsTotal: number; + timeouts: number; + restarts: number; + rejectedFull: number; + kernelStoreCacheMisses: number; + kernelStoreCacheEvictions: number; + transcriptStoreCacheMisses: number; + transcriptStoreCacheEvictions: number; + sqliteBusy: number; +}>; + +type ActorReady = Readonly<{ + ready: boolean; + actorVersion: number; + transportVersion: number; + workers: Readonly<{ ready: number; capacity: number }>; + lanes: readonly Lane[]; +}>; + +type GatewayStats = Readonly<{ schemaVersion: number }>; +type CancellationProof = Readonly<{ + schemaVersion: number; + durableCancellation: boolean; +}>; +type ActorTranscriptProof = Readonly<{ + placement: "actor"; + migrationComplete: boolean; + pendingMigrations: number; +}>; + +const MAX_DEADLINE_MS = 5_000; +const FAILED = Symbol("session-kernel-readiness-failed"); +const LANE_KEYS = [ + "index", "ready", "restarting", "queued", "executing", "turnsCompleted", + "queueWaitMsTotal", "busyMsTotal", "timeouts", "restarts", "rejectedFull", + "kernelStoreCacheMisses", "kernelStoreCacheEvictions", + "transcriptStoreCacheMisses", "transcriptStoreCacheEvictions", "sqliteBusy", +] as const; + +/** + * Builds the production-unwired SessionKernel readiness facade. All live I/O + * stays with injected bounded owners. Imports and construction perform no I/O. + */ +export function createSessionKernelReadinessFacade( + owners: Readonly, +): Readonly<{ + readiness(signal: AbortSignal): Promise; +}> { + return Object.freeze({ + async readiness(parentSignal: AbortSignal): Promise { + const controller = new AbortController(); + const abort = () => controller.abort(); + parentSignal.addEventListener("abort", abort, { once: true }); + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(); + resolve(FAILED); + }, boundedDeadline(owners.deadlineMs)); + }); + const read = async (owner: BoundedRead): Promise => { + if (parentSignal.aborted) return FAILED; + try { + return await Promise.race([ + Promise.resolve().then(() => owner(controller.signal)), + deadline, + ]); + } catch { + return FAILED; + } + }; + + const [actorRaw, statsRaw, cancellationRaw, transcriptsRaw] = await Promise.all([ + read(owners.actorReady), + read(owners.gatewayStats), + read(owners.cancellation), + read(owners.actorTranscripts), + ]); + if (timer !== undefined) clearTimeout(timer); + parentSignal.removeEventListener("abort", abort); + + const diagnostics: SessionKernelReadinessDiagnostic[] = []; + const add = (code: SessionKernelReadinessDiagnostic) => { + if (!diagnostics.includes(code)) diagnostics.push(code); + }; + const actor = decodeActorReady(actorRaw); + const stats = decodeGatewayStats(statsRaw); + const cancellation = decodeCancellationProof(cancellationRaw); + const transcripts = decodeActorTranscriptProof(transcriptsRaw); + + let actorCompatible = true; + if (!actor) { + add("actor_readiness_unavailable"); + actorCompatible = false; + } else { + if (actor.actorVersion !== SESSION_KERNEL_ACTOR_VERSION) { + add("actor_version_mismatch"); + actorCompatible = false; + } + if (actor.transportVersion !== SESSION_KERNEL_TRANSPORT_VERSION) { + add("transport_version_mismatch"); + actorCompatible = false; + } + const sessionLanes = actor.lanes.filter((lane) => lane.index !== 0); + const readyLanes = sessionLanes.filter((lane) => lane.ready); + const laneProof = actor.ready && actor.workers.ready >= 1 && + readyLanes.length >= 1 && actor.workers.ready === readyLanes.length && + actor.workers.capacity === sessionLanes.length; + if (!laneProof) { + add("no_ready_lane"); + actorCompatible = false; + } + if (actor.lanes.some((lane) => lane.queued > 0 || lane.rejectedFull > 0)) + add("lane_saturation_observed"); + if (actor.lanes.some((lane) => lane.restarting || lane.restarts > 0)) + add("lane_restart_observed"); + } + + let schemaCompatible = true; + if (!stats) { + add("gateway_stats_unavailable"); + schemaCompatible = false; + } else if (stats.schemaVersion !== SESSION_KERNEL_SCHEMA_VERSION) { + add("gateway_schema_mismatch"); + schemaCompatible = false; + } + + const cancellationAvailable = !!cancellation && + cancellation.schemaVersion === SESSION_KERNEL_SCHEMA_VERSION && + cancellation.durableCancellation === true; + if (!cancellationAvailable) add("cancellation_proof_unavailable"); + + let transcriptComplete = true; + if (!transcripts) { + add("actor_transcript_proof_unavailable"); + transcriptComplete = false; + } else if (!transcripts.migrationComplete || transcripts.pendingMigrations !== 0) { + add("actor_transcript_migration_incomplete"); + transcriptComplete = false; + } + + const ready = actorCompatible && schemaCompatible && + cancellationAvailable && transcriptComplete; + return Object.freeze({ + schemaVersion: ready ? SESSION_KERNEL_SCHEMA_VERSION : 0, + cancellationAvailable, + diagnostics: Object.freeze(diagnostics), + }); + }, + }); +} + +function decodeActorReady(value: unknown): ActorReady | undefined { + const object = exactRecord(value, [ + "ready", "actorVersion", "transportVersion", "workers", "lanes", + ]); + if (!object || typeof object.ready !== "boolean" || + !nonnegativeInteger(object.actorVersion) || + !nonnegativeInteger(object.transportVersion) || !Array.isArray(object.lanes)) + return; + const workers = exactRecord(object.workers, ["ready", "capacity"]); + if (!workers || !nonnegativeInteger(workers.ready) || + !nonnegativeInteger(workers.capacity) || workers.ready > workers.capacity) + return; + const lanes: Lane[] = []; + const indexes = new Set(); + for (const raw of object.lanes) { + const lane = decodeLane(raw); + if (!lane || indexes.has(lane.index)) return; + indexes.add(lane.index); + lanes.push(lane); + } + if (!indexes.has(0)) return; + return Object.freeze({ + ready: object.ready, + actorVersion: object.actorVersion, + transportVersion: object.transportVersion, + workers: Object.freeze({ ready: workers.ready, capacity: workers.capacity }), + lanes: Object.freeze(lanes), + }); +} + +function decodeLane(value: unknown): Lane | undefined { + const object = exactRecord(value, LANE_KEYS); + if (!object || typeof object.ready !== "boolean" || + typeof object.restarting !== "boolean") return; + for (const key of LANE_KEYS) { + if (key === "ready" || key === "restarting") continue; + if (!nonnegativeInteger(object[key])) return; + } + return Object.freeze(object as Lane); +} + +function decodeGatewayStats(value: unknown): GatewayStats | undefined { + const object = exactRecord(value, ["schemaVersion"]); + return object && nonnegativeInteger(object.schemaVersion) + ? Object.freeze({ schemaVersion: object.schemaVersion }) + : undefined; +} + +function decodeCancellationProof(value: unknown): CancellationProof | undefined { + const object = exactRecord(value, ["schemaVersion", "durableCancellation"]); + return object && nonnegativeInteger(object.schemaVersion) && + typeof object.durableCancellation === "boolean" + ? Object.freeze({ + schemaVersion: object.schemaVersion, + durableCancellation: object.durableCancellation, + }) + : undefined; +} + +function decodeActorTranscriptProof(value: unknown): ActorTranscriptProof | undefined { + const object = exactRecord(value, [ + "placement", "migrationComplete", "pendingMigrations", + ]); + return object && object.placement === "actor" && + typeof object.migrationComplete === "boolean" && + nonnegativeInteger(object.pendingMigrations) + ? Object.freeze({ + placement: "actor", + migrationComplete: object.migrationComplete, + pendingMigrations: object.pendingMigrations, + }) + : undefined; +} + +function exactRecord( + value: unknown, + expectedKeys: readonly string[], +): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Object.keys(descriptors).sort(); + const expected = [...expectedKeys].sort(); + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) + return; + for (const key of keys) { + const descriptor = descriptors[key]!; + if (!("value" in descriptor) || descriptor.enumerable !== true) return; + } + return Object.fromEntries(keys.map((key) => [key, descriptors[key]!.value])); +} + +function nonnegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function boundedDeadline(value: number | undefined): number { + if (!Number.isFinite(value)) return 1_000; + return Math.min(MAX_DEADLINE_MS, Math.max(1, Math.trunc(value!))); +} diff --git a/packages/core/opensession-server/src/server/agent-operation/sqlite-ledger.test.ts b/packages/core/opensession-server/src/server/agent-operation/sqlite-ledger.test.ts new file mode 100644 index 0000000000..db47755d3d --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/sqlite-ledger.test.ts @@ -0,0 +1,918 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentOperationAuthorizedQuery } from "./authorized-query"; +import type { + AgentOperationIdentity, + AgentOperationIndeterminateReason, + AgentOperationRecord, + AgentOperationTerminalReservation, +} from "./ledger"; +import { + AgentOperationConflictError, + AgentOperationLedgerFullError, + AgentOperationSessionActiveError, + AgentOperationTerminalReservedError, + AgentOperationTransitionError, + reconcileExecutingOperation, +} from "./ledger"; +import { SQLiteAgentOperationLedger } from "./sqlite-ledger"; + +const roots: string[] = []; +const path = () => { + const root = mkdtempSync(join(tmpdir(), "agent-operation-")); + roots.push(root); + return join(root, "operations.sqlite"); +}; +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); +}); +const d = (c: string) => `sha256:${c.repeat(64)}` as const; +const identity = ( + overrides: Partial = {}, +): AgentOperationIdentity => ({ + operationId: "operation-1", + kind: "model", + fence: { + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, + }, + planHash: d("a"), + authorityHash: d("b"), + supervisorEpoch: 4, + hostId: "host-1", + hostGeneration: 2, + hostIncarnation: "incarnation-1", + transcriptAnchor: { + throughChangeSeq: 2, + entryIds: ["entry-1"], + digest: d("c"), + }, + descriptor: { + version: 1, + kind: "model", + stepId: "step-1", + transcript: { throughChangeSeq: 2, entryIds: ["entry-1"], digest: d("c") }, + modelPolicyHash: d("d"), + adapterRequestVersion: "v1", + }, + descriptorDigest: + "sha256:4eff910ea108e76902e2dbc225430801b9b121ea84932063a6269ef671d4ac5e", + payloadDigest: d("f"), + adapterId: "adapter-1", + adapterVersion: "1.0", + ...overrides, +}); +const authorizedQuery = ( + source: AgentOperationIdentity = identity(), + overrides: Partial = {}, +): AgentOperationAuthorizedQuery => + ({ + mode: "exact", + operationId: source.operationId, + kind: source.kind, + fence: source.fence, + descriptorDigest: source.descriptorDigest, + payloadDigest: source.payloadDigest, + authority: { + planHash: source.planHash, + authorityHash: source.authorityHash, + supervisorEpoch: source.supervisorEpoch, + hostId: source.hostId, + hostGeneration: source.hostGeneration, + hostIncarnation: source.hostIncarnation, + }, + ...overrides, + }) as AgentOperationAuthorizedQuery; +const transcriptRefs = [ + { + appendId: "append-1", + entryIds: ["entry-2"], + firstSeq: 3, + lastSeq: 3, + throughChangeSeq: 3, + requestDigest: d("2"), + }, +]; +const settlement = { + completedAtMs: 3, + outcome: { + status: "succeeded" as const, + outputDigest: d("1"), + usage: { inputTokens: 4, outputTokens: 2 }, + }, + transcriptRefs, + kernelTerminal: { + outputDigest: d("1"), + outcomeCode: "ok", + transcriptRefs, + pendingToolUseEntryIds: [] as string[], + }, +}; +const terminalFor = (reason: AgentOperationIndeterminateReason) => ({ + outputDigest: d("3"), + outcomeCode: reason, + transcriptRefs, + pendingToolUseEntryIds: [] as string[], +}); +const indeterminateTerminal = async ( + _record: AgentOperationIdentity, + reservation: Readonly, +) => terminalFor(reservation.reason); + +describe("SQLite Agent operation ledger", () => { + test("persists exact prepared, executing and settled replay across every reopen boundary", async () => { + const dbPath = path(); + const exact = identity(); + let ledger = new SQLiteAgentOperationLedger({ dbPath }); + expect((await ledger.claimPrepared(exact, 1)).claimed).toBe(true); + await ledger.close(); + ledger = new SQLiteAgentOperationLedger({ dbPath }); + expect( + (await ledger.claimPrepared(exact, 99)).record.receipt.acceptedAtMs, + ).toBe(1); + expect((await ledger.markExecuting(exact, 2)).receipt.state).toBe( + "executing", + ); + await ledger.close(); + ledger = new SQLiteAgentOperationLedger({ dbPath }); + const terminal = await ledger.settle(exact, settlement); + expect(terminal.receipt).toMatchObject({ + state: "settled", + completedAtMs: 3, + outcome: settlement.outcome, + }); + await ledger.close(); + ledger = new SQLiteAgentOperationLedger({ dbPath }); + expect((await ledger.claimPrepared(exact, 100)).record.receipt).toEqual( + terminal.receipt, + ); + expect(await ledger.scanActive()).toEqual([]); + await ledger.close(); + }); + + test("queries active and terminal receipts across reopen without dispatch grant or policy state", async () => { + const dbPath = path(); + const exact = identity(); + const query = authorizedQuery(exact); + let ledger = new SQLiteAgentOperationLedger({ dbPath }); + const prepared = (await ledger.claimPrepared(exact, 1)).record; + expect(await ledger.queryAuthorized(query)).toEqual(prepared); + await ledger.markExecuting(exact, 2); + await ledger.close(); + + ledger = new SQLiteAgentOperationLedger({ dbPath }); + expect((await ledger.queryAuthorized(query))?.receipt.state).toBe( + "executing", + ); + const terminal = await ledger.settle(exact, settlement); + await ledger.close(); + + ledger = new SQLiteAgentOperationLedger({ dbPath }); + expect(await ledger.queryAuthorized(query)).toEqual(terminal); + expect(JSON.stringify(query)).not.toMatch(/grant|policy/i); + await ledger.close(); + }); + + test("returns no authorized receipt for every identity or authority crossover with zero mutation", async () => { + const dbPath = path(); + const exact = identity(); + const ledger = new SQLiteAgentOperationLedger({ dbPath }); + const original = (await ledger.claimPrepared(exact, 1)).record; + const base = authorizedQuery(exact); + const alternateMcp = identity({ + operationId: exact.operationId, + kind: "mcp", + toolUseEntryId: "entry-1", + descriptorDigest: + "sha256:436fa9871b12e7650a5df3675f2683c79d080d64dcdd81a7632cb1dc9dc0eb1c", + descriptor: { + version: 1, + kind: "mcp", + toolUseEntryId: "entry-1", + toolUseId: "use-1", + server: "server-1", + tool: "tool-1", + argumentsDigest: d("9"), + adapterRequestVersion: "v1", + }, + }); + const mismatches: AgentOperationAuthorizedQuery[] = [ + authorizedQuery(exact, { operationId: "operation-2" }), + authorizedQuery(exact, { + fence: { ...exact.fence, sessionId: "session-2" }, + }), + authorizedQuery(exact, { fence: { ...exact.fence, runId: "run-2" } }), + authorizedQuery(exact, { + fence: { ...exact.fence, turnId: "turn-2" }, + }), + authorizedQuery(exact, { + fence: { ...exact.fence, generation: 2 }, + }), + authorizedQuery(exact, { kind: alternateMcp.kind }), + authorizedQuery(exact, { descriptorDigest: d("1") }), + authorizedQuery(exact, { payloadDigest: d("2") }), + authorizedQuery(exact, { + authority: { ...base.authority, planHash: d("3") }, + }), + authorizedQuery(exact, { + authority: { ...base.authority, authorityHash: d("4") }, + }), + authorizedQuery(exact, { + authority: { ...base.authority, supervisorEpoch: 3 }, + }), + authorizedQuery(exact, { + authority: { ...base.authority, hostId: "host-2" }, + }), + authorizedQuery(exact, { + authority: { ...base.authority, hostGeneration: 1 }, + }), + authorizedQuery(exact, { + authority: { + ...base.authority, + hostIncarnation: "incarnation-stale", + }, + }), + ]; + for (const mismatch of mismatches) + expect(await ledger.queryAuthorized(mismatch)).toBeUndefined(); + + const inspection = new Database(dbPath, { readonly: true }); + expect( + inspection + .query<{ quarantine_reason: string | null }, []>( + "SELECT quarantine_reason FROM agent_operation_receipts", + ) + .get()!.quarantine_reason, + ).toBeNull(); + inspection.close(); + expect(await ledger.queryAuthorized(base)).toEqual(original); + await ledger.close(); + }); + + test("allows payload omission only for explicit recovery without weakening other bindings", async () => { + const dbPath = path(); + const exact = identity(); + let ledger = new SQLiteAgentOperationLedger({ dbPath }); + await ledger.claimPrepared(exact, 1); + const { payloadDigest: _payload, ...recoveryBase } = authorizedQuery(exact); + const recovery = { ...recoveryBase, mode: "recovery" as const }; + expect((await ledger.queryAuthorized(recovery))?.operationId).toBe( + exact.operationId, + ); + await expect( + ledger.queryAuthorized({ ...recoveryBase, mode: "exact" } as never), + ).rejects.toThrow("requires payload digest"); + expect( + await ledger.queryAuthorized({ + ...recovery, + fence: { ...recovery.fence, generation: 2 }, + }), + ).toBeUndefined(); + expect( + await ledger.queryAuthorized({ + ...recovery, + descriptorDigest: d("8"), + }), + ).toBeUndefined(); + expect( + await ledger.queryAuthorized({ + ...recovery, + payloadDigest: d("7"), + }), + ).toBeUndefined(); + expect( + await ledger.queryAuthorized({ + ...recovery, + authority: { ...recovery.authority, hostGeneration: 1 }, + }), + ).toBeUndefined(); + await ledger.markExecuting(exact, 2); + const terminal = await ledger.settle(exact, settlement); + await ledger.close(); + ledger = new SQLiteAgentOperationLedger({ dbPath }); + expect(await ledger.queryAuthorized(recovery)).toEqual(terminal); + await ledger.close(); + }); + + test("strictly rejects prototypes, accessors and diagnostic secrets before reading", async () => { + const dbPath = path(); + const exact = identity(); + const ledger = new SQLiteAgentOperationLedger({ dbPath }); + await ledger.claimPrepared(exact, 1); + const base = authorizedQuery(exact); + let getterCalls = 0; + const accessor = { ...base } as Record; + Object.defineProperty(accessor, "operationId", { + enumerable: true, + get() { + getterCalls++; + return exact.operationId; + }, + }); + await expect(ledger.queryAuthorized(accessor as never)).rejects.toThrow( + "invalid authorized Agent operation query", + ); + expect(getterCalls).toBe(0); + + const inherited = Object.assign(Object.create({ leaked: true }), base); + await expect(ledger.queryAuthorized(inherited)).rejects.toThrow( + "invalid authorized Agent operation query", + ); + const secret = { ...base, bearerGrant: "diagnostic-secret-value" }; + let message = ""; + try { + await ledger.queryAuthorized(secret as never); + } catch (error) { + message = String(error); + } + expect(message).not.toContain("diagnostic-secret-value"); + expect(await ledger.queryAuthorized(base)).toBeDefined(); + await ledger.close(); + }); + + test("serializes concurrent duplicate claims to one durable record", async () => { + const dbPath = path(); + const first = new SQLiteAgentOperationLedger({ dbPath }); + const second = new SQLiteAgentOperationLedger({ dbPath }); + const results = await Promise.all([ + first.claimPrepared(identity(), 1), + second.claimPrepared(identity(), 1), + ]); + expect(results.filter((r) => r.claimed)).toHaveLength(1); + expect(results[0].record.receipt).toEqual(results[1].record.receipt); + await first.close(); + await second.close(); + }); + + test("strictly canonicalizes and verifies descriptors before any durable write", async () => { + const dbPath = path(); + const ledger = new SQLiteAgentOperationLedger({ dbPath }); + const malicious = identity({ + descriptor: { + ...identity().descriptor, + prompt: "do not persist", + credentials: { token: "secret-value" }, + } as never, + }); + await expect(ledger.claimPrepared(malicious, 1)).rejects.toThrow(); + await expect( + ledger.claimPrepared( + identity({ operationId: "operation-2", descriptorDigest: d("3") }), + 1, + ), + ).rejects.toThrow("descriptor digest does not match"); + const inspection = new Database(dbPath, { readonly: true }); + expect( + JSON.stringify( + inspection.query("SELECT * FROM agent_operation_receipts").all(), + ), + ).not.toMatch(/do not persist|secret-value/); + inspection.close(); + await ledger.close(); + }); + + test("atomically quarantines every exact identity mismatch without overwriting", async () => { + const dbPath = path(); + const ledger = new SQLiteAgentOperationLedger({ dbPath }); + const original = identity(); + await ledger.claimPrepared(original, 1); + const mismatches: AgentOperationIdentity[] = [ + identity({ + kind: "mcp", + toolUseEntryId: "entry-1", + descriptorDigest: + "sha256:436fa9871b12e7650a5df3675f2683c79d080d64dcdd81a7632cb1dc9dc0eb1c", + descriptor: { + version: 1, + kind: "mcp", + toolUseEntryId: "entry-1", + toolUseId: "use-1", + server: "server-1", + tool: "tool-1", + argumentsDigest: d("9"), + adapterRequestVersion: "v1", + }, + }), + identity({ fence: { ...original.fence, runId: "run-2" } }), + identity({ fence: { ...original.fence, turnId: "turn-2" } }), + identity({ fence: { ...original.fence, generation: 2 } }), + identity({ planHash: d("1") }), + identity({ authorityHash: d("2") }), + identity({ supervisorEpoch: 5 }), + identity({ hostId: "host-2" }), + identity({ hostGeneration: 3 }), + identity({ hostIncarnation: "incarnation-2" }), + identity({ payloadDigest: d("4") }), + identity({ adapterId: "adapter-2" }), + identity({ adapterVersion: "2.0" }), + ]; + for (const mismatch of mismatches) + await expect(ledger.claimPrepared(mismatch, 1)).rejects.toBeInstanceOf( + AgentOperationConflictError, + ); + expect((await ledger.getExact(original))?.quarantineReason).toContain( + "mismatch", + ); + expect((await ledger.getExact(original))?.planHash).toBe(original.planHash); + expect(await ledger.scanActive()).toEqual([]); + await expect(ledger.claimPrepared(original, 1)).rejects.toBeInstanceOf( + AgentOperationConflictError, + ); + await expect(ledger.markExecuting(original, 2)).rejects.toBeInstanceOf( + AgentOperationConflictError, + ); + await ledger.close(); + }); + + test("rejects illegal and backward transitions", async () => { + const ledger = new SQLiteAgentOperationLedger({ dbPath: path() }); + const exact = identity(); + await ledger.claimPrepared(exact, 1); + await expect(ledger.settle(exact, settlement)).rejects.toBeInstanceOf( + AgentOperationTransitionError, + ); + await ledger.markExecuting(exact, 2); + await ledger.settle(exact, settlement); + await expect(ledger.markExecuting(exact, 4)).rejects.toBeInstanceOf( + AgentOperationTransitionError, + ); + await expect( + ledger.reserveIndeterminate(exact, "ambiguous_completion", 4), + ).rejects.toBeInstanceOf(AgentOperationTransitionError); + await ledger.close(); + }); + + test("leaves prepared replayable and makes inherited executing visibly indeterminate when reconciliation is unsupported", async () => { + const dbPath = path(); + let ledger = new SQLiteAgentOperationLedger({ dbPath }); + await ledger.claimPrepared(identity(), 1); + await ledger.claimPrepared(identity({ operationId: "operation-2" }), 1); + await ledger.markExecuting(identity({ operationId: "operation-2" }), 2); + await ledger.close(); + ledger = new SQLiteAgentOperationLedger({ dbPath }); + const active = await ledger.scanActive(); + expect(active.map((r) => r.receipt.state)).toEqual([ + "prepared", + "executing", + ]); + const recovered = await reconcileExecutingOperation( + ledger, + active[1], + undefined, + indeterminateTerminal, + 4, + ); + expect(recovered.receipt).toMatchObject({ + state: "indeterminate", + errorCode: "reconciliation_unsupported", + transcriptRefs, + kernelTerminal: terminalFor("reconciliation_unsupported"), + }); + expect((await ledger.scanActive()).map((r) => r.receipt.state)).toEqual([ + "prepared", + ]); + await ledger.close(); + ledger = new SQLiteAgentOperationLedger({ dbPath }); + expect((await ledger.getExact(active[1]))!.receipt).toEqual( + recovered.receipt, + ); + await ledger.close(); + }); + + test("reserves indeterminate ownership before transcript I/O and survives restart", async () => { + const dbPath = path(); + const exact = identity(); + let ledger = new SQLiteAgentOperationLedger({ dbPath }); + await ledger.claimPrepared(exact, 1); + await ledger.markExecuting(exact, 2); + const reservation = await ledger.reserveIndeterminate( + exact, + "reconciliation_unsupported", + 3, + ); + await ledger.close(); + + ledger = new SQLiteAgentOperationLedger({ dbPath }); + const reservedRecord = (await ledger.getExact(exact))!; + expect(reservedRecord.terminalReservation).toEqual(reservation); + expect( + await ledger.reserveIndeterminate(exact, "reconciliation_failed", 4), + ).toEqual(reservation); + await expect(ledger.settle(exact, settlement)).rejects.toBeInstanceOf( + AgentOperationTerminalReservedError, + ); + let callbackCalls = 0; + let adapterCalls = 0; + const recovered = await reconcileExecutingOperation( + ledger, + reservedRecord, + { + reconcile: async () => { + adapterCalls += 1; + throw new Error("reserved recovery must not consult adapter"); + }, + }, + async (record, owned) => { + callbackCalls += 1; + expect(owned).toEqual(reservation); + await expect(ledger.settle(record, settlement)).rejects.toBeInstanceOf( + AgentOperationTerminalReservedError, + ); + return terminalFor(owned.reason); + }, + 4, + ); + expect(adapterCalls).toBe(0); + expect(callbackCalls).toBe(1); + expect(recovered.receipt).toMatchObject({ + state: "indeterminate", + errorCode: "reconciliation_unsupported", + }); + expect(recovered.terminalReservation).toBeUndefined(); + await ledger.close(); + }); + + test("does not append indeterminate evidence after settlement wins terminal ownership", async () => { + const ledger = new SQLiteAgentOperationLedger({ dbPath: path() }); + const exact = identity(); + await ledger.claimPrepared(exact, 1); + const staleExecuting = await ledger.markExecuting(exact, 2); + const settled = await ledger.settle(exact, settlement); + let callbackCalls = 0; + const recovered = await reconcileExecutingOperation( + ledger, + staleExecuting, + undefined, + async () => { + callbackCalls += 1; + return terminalFor("reconciliation_unsupported"); + }, + 4, + ); + expect(callbackCalls).toBe(0); + expect(recovered.receipt).toEqual(settled.receipt); + await ledger.close(); + }); + + test("authenticates a recovered reservation before transcript I/O", async () => { + const ledger = new SQLiteAgentOperationLedger({ dbPath: path() }); + const exact = identity(); + await ledger.claimPrepared(exact, 1); + const executing = await ledger.markExecuting(exact, 2); + let callbackCalls = 0; + const callback = async ( + _record: AgentOperationRecord, + reservation: Readonly, + ) => { + callbackCalls += 1; + return terminalFor(reservation.reason); + }; + const forgedReservation = { + reservationId: `reservation:${"9".repeat(64)}`, + reason: "reconciliation_unsupported" as const, + reservedAtMs: 3, + }; + await expect( + reconcileExecutingOperation( + ledger, + { ...executing, terminalReservation: forgedReservation }, + undefined, + callback, + 3, + ), + ).rejects.toBeInstanceOf(AgentOperationConflictError); + expect(callbackCalls).toBe(0); + expect((await ledger.getExact(exact))?.terminalReservation).toBeUndefined(); + + const durable = await ledger.reserveIndeterminate( + exact, + "reconciliation_unsupported", + 3, + ); + const reserved = (await ledger.getExact(exact))!; + await expect( + reconcileExecutingOperation( + ledger, + { + ...reserved, + terminalReservation: { + ...durable, + reservationId: `reservation:${"8".repeat(64)}`, + }, + }, + undefined, + callback, + 4, + ), + ).rejects.toBeInstanceOf(AgentOperationConflictError); + expect(callbackCalls).toBe(0); + expect((await ledger.getExact(exact))?.terminalReservation).toEqual( + durable, + ); + await ledger.close(); + }); + + test("requires exact adapter reconciliation proof and adopts a supported terminal proof", async () => { + const ledger = new SQLiteAgentOperationLedger({ dbPath: path() }); + const exact = identity(); + await ledger.claimPrepared(exact, 1); + const executing = await ledger.markExecuting(exact, 2); + const result = await reconcileExecutingOperation( + ledger, + executing, + { + reconcile: async () => ({ + status: "settled", + proof: { + adapterId: exact.adapterId, + adapterVersion: exact.adapterVersion, + operationId: exact.operationId, + kind: exact.kind, + fence: exact.fence, + planHash: exact.planHash, + authorityHash: exact.authorityHash, + descriptorDigest: exact.descriptorDigest, + payloadDigest: exact.payloadDigest, + providerResponseRef: "response-1", + }, + settlement: { ...settlement, providerResponseRef: "response-1" }, + }), + }, + indeterminateTerminal, + 4, + ); + expect(result.receipt.state).toBe("settled"); + await ledger.close(); + }); + + test("fails closed on malformed or contradictory reconciliation output", async () => { + for (const malformed of [ + { status: "settled" }, + { + status: "settled", + proof: { + adapterId: "adapter-1", + adapterVersion: "1.0", + operationId: "operation-1", + kind: "model", + fence: identity().fence, + planHash: identity().planHash, + authorityHash: identity().authorityHash, + descriptorDigest: identity().descriptorDigest, + payloadDigest: identity().payloadDigest, + providerResponseRef: "different-response", + }, + settlement, + }, + ]) { + const ledger = new SQLiteAgentOperationLedger({ dbPath: path() }); + const exact = identity(); + await ledger.claimPrepared(exact, 1); + const executing = await ledger.markExecuting(exact, 2); + const result = await reconcileExecutingOperation( + ledger, + executing, + { reconcile: async () => malformed as never }, + indeterminateTerminal, + 3, + ); + expect(result.receipt).toMatchObject({ + state: "indeterminate", + errorCode: "reconciliation_failed", + }); + await ledger.close(); + } + }); + + test("does not treat cancellation, timeout, AbortError or disconnect as settlement", async () => { + for (const reason of [ + "cancellation_ambiguous", + "timeout_ambiguous", + "disconnect_ambiguous", + ] as const) { + const ledger = new SQLiteAgentOperationLedger({ dbPath: path() }); + const exact = identity(); + await ledger.claimPrepared(exact, 1); + await ledger.markExecuting(exact, 2); + const reservation = await ledger.reserveIndeterminate(exact, reason, 3); + expect( + ( + await ledger.markIndeterminate( + exact, + reservation, + 3, + terminalFor(reason), + ) + ).receipt.state, + ).toBe("indeterminate"); + await ledger.close(); + } + const ledger = new SQLiteAgentOperationLedger({ dbPath: path() }); + const exact = identity(); + await ledger.claimPrepared(exact, 1); + const executing = await ledger.markExecuting(exact, 2); + const recovered = await reconcileExecutingOperation( + ledger, + executing, + { + reconcile: async () => { + throw new DOMException("aborted", "AbortError"); + }, + }, + indeterminateTerminal, + 3, + ); + expect(recovered.receipt.errorCode).toBe("reconciliation_failed"); + await ledger.close(); + }); + + test("enforces capacity, row bounds, retirement and authoritative session deletion", async () => { + const ledger = new SQLiteAgentOperationLedger({ + dbPath: path(), + capacity: 1, + }); + await ledger.claimPrepared(identity(), 1); + await expect( + ledger.claimPrepared(identity({ operationId: "operation-2" }), 1), + ).rejects.toBeInstanceOf(AgentOperationLedgerFullError); + await expect(ledger.retireSession("session-1")).rejects.toBeInstanceOf( + AgentOperationSessionActiveError, + ); + expect(await ledger.deleteSession("session-1")).toBe(1); + expect(await ledger.scanActive()).toEqual([]); + await ledger.close(); + const bounded = new SQLiteAgentOperationLedger({ + dbPath: path(), + maxRowBytes: 256, + }); + await expect(bounded.claimPrepared(identity(), 1)).rejects.toBeInstanceOf( + AgentOperationLedgerFullError, + ); + await bounded.close(); + }); + + test("creates an exact private schema with no body/secret columns or serialized payloads", async () => { + const dbPath = path(); + const ledger = new SQLiteAgentOperationLedger({ dbPath }); + await ledger.claimPrepared(identity(), 1); + const inspection = new Database(dbPath, { readonly: true }); + const sql = inspection + .query<{ sql: string }, [string]>( + "SELECT sql FROM sqlite_master WHERE name=?", + ) + .get("agent_operation_receipts")!.sql; + const columns = inspection + .query<{ name: string }, []>( + "PRAGMA table_info('agent_operation_receipts')", + ) + .all() + .map((r) => r.name); + const rows = JSON.stringify( + inspection.query("SELECT * FROM agent_operation_receipts").all(), + ); + inspection.close(); + expect(sql).toContain("PRIMARY KEY(session_id,operation_id)"); + expect(columns).not.toEqual( + expect.arrayContaining([ + "body", + "prompt", + "arguments", + "credentials", + "headers", + "url", + "response_body", + ]), + ); + expect(rows).not.toMatch( + /authorization|apiKey|credentials|prompt|responseBody|toolInput/i, + ); + expect(statSync(dbPath).mode & 0o777).toBe(0o600); + await ledger.close(); + }); + + test("fails closed on unsafe sidecars, schema and row tampering", async () => { + const sidecar = path(); + const target = `${sidecar}.target`; + writeFileSync(target, "x"); + symlinkSync(target, `${sidecar}-wal`); + expect(() => new SQLiteAgentOperationLedger({ dbPath: sidecar })).toThrow( + "unsafe Agent operation ledger SQLite file", + ); + const lookalike = path(); + const weak = new Database(lookalike); + weak.exec(`CREATE TABLE agent_operation_receipts ( + session_id TEXT, operation_id TEXT, kind TEXT, run_id TEXT, turn_id TEXT, generation INTEGER, + plan_hash TEXT, authority_hash TEXT, descriptor_digest TEXT, payload_digest TEXT, + adapter_id TEXT, adapter_version TEXT, state TEXT, accepted_at INTEGER, executing_at INTEGER, + completed_at INTEGER, descriptor_json TEXT, receipt_json TEXT, quarantine_reason TEXT, ordinal INTEGER + ); PRAGMA user_version=1;`); + weak.close(); + expect(() => new SQLiteAgentOperationLedger({ dbPath: lookalike })).toThrow( + "unsupported Agent operation ledger schema: 1", + ); + + const unknown = path(); + const db = new Database(unknown); + db.exec("PRAGMA user_version=3"); + db.close(); + expect(() => new SQLiteAgentOperationLedger({ dbPath: unknown })).toThrow( + "unsupported Agent operation ledger schema", + ); + const tampered = path(); + let ledger = new SQLiteAgentOperationLedger({ dbPath: tampered }); + await ledger.claimPrepared(identity(), 1); + await ledger.close(); + const mutation = new Database(tampered); + mutation + .query("UPDATE agent_operation_receipts SET receipt_json=?") + .run('{"body":"secret"}'); + mutation.close(); + ledger = new SQLiteAgentOperationLedger({ dbPath: tampered }); + await expect(ledger.scanActive()).rejects.toThrow( + "corrupt Agent operation ledger row", + ); + await ledger.close(); + + const authorizedPath = path(); + ledger = new SQLiteAgentOperationLedger({ dbPath: authorizedPath }); + await ledger.claimPrepared(identity(), 1); + await ledger.close(); + const authorizedTamper = new Database(authorizedPath); + authorizedTamper + .query("UPDATE agent_operation_receipts SET plan_hash=?") + .run(d("9")); + authorizedTamper.close(); + ledger = new SQLiteAgentOperationLedger({ dbPath: authorizedPath }); + await expect(ledger.queryAuthorized(authorizedQuery())).rejects.toThrow( + "tampered Agent operation ledger receipt", + ); + await ledger.close(); + + const reservationPath = path(); + ledger = new SQLiteAgentOperationLedger({ dbPath: reservationPath }); + await ledger.claimPrepared(identity(), 1); + await ledger.markExecuting(identity(), 2); + await ledger.reserveIndeterminate( + identity(), + "reconciliation_unsupported", + 3, + ); + await ledger.close(); + const reservationTamper = new Database(reservationPath); + reservationTamper + .query( + "UPDATE agent_operation_receipts SET terminal_reservation_reason=?", + ) + .run("reconciliation_failed"); + reservationTamper.close(); + ledger = new SQLiteAgentOperationLedger({ dbPath: reservationPath }); + await expect(ledger.scanActive()).rejects.toThrow( + "tampered Agent operation ledger receipt", + ); + await ledger.close(); + + const quarantinePath = path(); + ledger = new SQLiteAgentOperationLedger({ dbPath: quarantinePath }); + await ledger.claimPrepared(identity(), 1); + await ledger.close(); + const quarantineTamper = new Database(quarantinePath); + quarantineTamper.exec("PRAGMA ignore_check_constraints=ON"); + quarantineTamper + .query("UPDATE agent_operation_receipts SET quarantine_reason=?") + .run("secret arbitrary metadata"); + quarantineTamper.close(); + expect( + () => new SQLiteAgentOperationLedger({ dbPath: quarantinePath }), + ).toThrow(); + }); + + test("close is idempotent, drains WAL and rejects later access", async () => { + const dbPath = path(); + const ledger = new SQLiteAgentOperationLedger({ dbPath }); + await ledger.claimPrepared(identity(), 1); + await ledger.close(); + await ledger.close(); + expect(readFileSync(dbPath).subarray(0, 16).toString()).toBe( + "SQLite format 3\u0000", + ); + await expect(ledger.scanActive()).rejects.toThrow("closed"); + chmodSync(dbPath, 0o644); + const reopened = new SQLiteAgentOperationLedger({ dbPath }); + expect(statSync(dbPath).mode & 0o777).toBe(0o600); + await reopened.close(); + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/sqlite-ledger.ts b/packages/core/opensession-server/src/server/agent-operation/sqlite-ledger.ts new file mode 100644 index 0000000000..4b5c420629 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/sqlite-ledger.ts @@ -0,0 +1,1054 @@ +import { Database } from "bun:sqlite"; +import { + chmodSync, + closeSync, + constants, + fstatSync, + lstatSync, + mkdirSync, + openSync, +} from "node:fs"; +import { createHash, randomBytes } from "node:crypto"; +import { dirname, parse, resolve } from "node:path"; +import { + AGENT_OPERATION_DESCRIPTOR_DIGEST_DOMAIN, + decodeAgentOperationDescriptorV1, + decodeAgentOperationReceiptV1, + serializeAgentOperationDescriptorV1, + serializeAgentOperationReceiptV1, + type AgentOperationReceiptV1, +} from "@tellahq/opensession-protocol/agent-operation"; +import { + decodeAgentOperationAuthorizedQuery, + type AgentOperationAuthorizedQuery, +} from "./authorized-query"; +import { + AgentOperationConflictError, + AgentOperationLedgerFullError, + AgentOperationNotFoundError, + AgentOperationSessionActiveError, + AgentOperationTerminalReservedError, + AgentOperationTransitionError, + type AgentOperationIdentity, + type AgentOperationIndeterminateReason, + type AgentOperationLedger, + type AgentOperationQuarantineReason, + type AgentOperationRecord, + type AgentOperationSettlement, + type AgentOperationTerminalReservation, +} from "./ledger"; + +const SCHEMA_VERSION = 2; +const MAX_ID_BYTES = 512; +const DEFAULT_MAX_ROW_BYTES = 256 * 1024; +const TABLE_SQL = `CREATE TABLE agent_operation_receipts ( + session_id TEXT NOT NULL, operation_id TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('model','mcp')), + run_id TEXT NOT NULL, turn_id TEXT NOT NULL, generation INTEGER NOT NULL CHECK(generation>=0), + plan_hash TEXT NOT NULL, authority_hash TEXT NOT NULL, descriptor_digest TEXT NOT NULL, payload_digest TEXT NOT NULL, + adapter_id TEXT NOT NULL, adapter_version TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('prepared','executing','settled','indeterminate')), + accepted_at INTEGER NOT NULL, executing_at INTEGER, completed_at INTEGER, + terminal_reservation_id TEXT, terminal_reservation_reason TEXT CHECK(terminal_reservation_reason IN ('reconciliation_unsupported','reconciliation_failed','ambiguous_completion','identity_mismatch','cancellation_ambiguous','timeout_ambiguous','disconnect_ambiguous')), terminal_reserved_at INTEGER, + descriptor_json TEXT NOT NULL, receipt_json TEXT NOT NULL, + quarantine_reason TEXT CHECK(quarantine_reason IN ('claim_identity_mismatch','get_identity_mismatch','transition_identity_mismatch')), + ordinal INTEGER NOT NULL, + PRIMARY KEY(session_id,operation_id) +) STRICT`; +const ACTIVE_INDEX_SQL = + "CREATE INDEX agent_operation_active ON agent_operation_receipts(state,ordinal) WHERE state IN ('prepared','executing')"; +const encoder = new TextEncoder(); +class CommittedConflict extends Error {} +type Row = { + session_id: unknown; + operation_id: unknown; + kind: unknown; + run_id: unknown; + turn_id: unknown; + generation: unknown; + plan_hash: unknown; + authority_hash: unknown; + descriptor_digest: unknown; + payload_digest: unknown; + adapter_id: unknown; + adapter_version: unknown; + state: unknown; + accepted_at: unknown; + executing_at: unknown; + completed_at: unknown; + terminal_reservation_id: unknown; + terminal_reservation_reason: unknown; + terminal_reserved_at: unknown; + descriptor_json: unknown; + receipt_json: unknown; + quarantine_reason: unknown; + ordinal: unknown; +}; +export interface SQLiteAgentOperationLedgerOptions { + dbPath: string; + capacity?: number; + busyTimeoutMs?: number; + maxRowBytes?: number; +} + +export class SQLiteAgentOperationLedger implements AgentOperationLedger { + readonly #db: Database; + readonly #capacity: number; + readonly #maxRowBytes: number; + readonly #path: string; + #closed = false; + + constructor(options: SQLiteAgentOperationLedgerOptions) { + if (!options.dbPath || options.dbPath === ":memory:") + throw new Error( + "a filesystem database path is required for the Agent operation ledger", + ); + this.#capacity = positive(options.capacity ?? 100_000, "capacity"); + this.#maxRowBytes = positive( + options.maxRowBytes ?? DEFAULT_MAX_ROW_BYTES, + "row byte limit", + ); + const timeout = positive(options.busyTimeoutMs ?? 5_000, "busy timeout"); + this.#path = resolve(options.dbPath); + preparePrivatePath(this.#path); + preflightSidecars(this.#path); + const db = new Database(this.#path, { create: true, strict: true }); + try { + db.exec(`PRAGMA busy_timeout = ${timeout}; PRAGMA foreign_keys = ON;`); + initialize(db); + db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;"); + secureFiles(this.#path); + this.#db = db; + } catch (error) { + db.close(); + throw error; + } + } + + async claimPrepared( + identity: AgentOperationIdentity, + acceptedAtMs: number, + ): Promise<{ record: AgentOperationRecord; claimed: boolean }> { + this.#open(); + validateIdentity(identity); + validTime(acceptedAtMs, "acceptedAtMs"); + const receipt: AgentOperationReceiptV1 = { + version: 1, + operationId: identity.operationId, + kind: identity.kind, + fence: identity.fence, + planHash: identity.planHash, + authorityHash: identity.authorityHash, + descriptorDigest: identity.descriptorDigest, + payloadDigest: identity.payloadDigest, + actorIdentity: { + supervisorEpoch: identity.supervisorEpoch, + hostId: identity.hostId, + hostGeneration: identity.hostGeneration, + hostIncarnation: identity.hostIncarnation, + transcriptAnchor: identity.transcriptAnchor, + ...(identity.kind === "mcp" + ? { toolUseEntryId: identity.toolUseEntryId } + : {}), + }, + state: "prepared", + acceptedAtMs, + providerRef: { + adapterId: identity.adapterId, + adapterVersion: identity.adapterVersion, + }, + }; + const record = { ...identity, receipt }; + const descriptorJson = canonicalDescriptorJson(identity); + const receiptJson = encodeReceipt(receipt, this.#maxRowBytes); + rowLimit(descriptorJson, receiptJson, this.#maxRowBytes); + return this.#transaction(() => { + const existing = this.#select( + identity.fence.sessionId, + identity.operationId, + ); + if (existing) { + const decoded = decodeRow(existing, this.#maxRowBytes); + if (!sameIdentity(decoded, identity)) + this.#conflict(identity, "claim_identity_mismatch"); + if (decoded.quarantineReason) + throw new AgentOperationConflictError( + "agent operation is quarantined", + ); + return { record: decoded, claimed: false }; + } + const count = this.#db + .query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM agent_operation_receipts", + ) + .get()!.count; + if (count >= this.#capacity) throw new AgentOperationLedgerFullError(); + const ordinal = this.#db + .query<{ value: number }, []>( + "SELECT COALESCE(MAX(ordinal), 0) + 1 AS value FROM agent_operation_receipts", + ) + .get()!.value; + this.#db + .query( + `INSERT INTO agent_operation_receipts + (session_id,operation_id,kind,run_id,turn_id,generation,plan_hash,authority_hash,descriptor_digest,payload_digest, + adapter_id,adapter_version,state,accepted_at,executing_at,completed_at,descriptor_json,receipt_json,quarantine_reason,ordinal) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,NULL,?,?,NULL,?)`, + ) + .run( + identity.fence.sessionId, + identity.operationId, + identity.kind, + identity.fence.runId, + identity.fence.turnId, + identity.fence.generation, + identity.planHash, + identity.authorityHash, + identity.descriptorDigest, + identity.payloadDigest, + identity.adapterId, + identity.adapterVersion, + "prepared", + acceptedAtMs, + descriptorJson, + receiptJson, + ordinal, + ); + return { record: structuredClone(record), claimed: true }; + }); + } + + async markExecuting( + identity: AgentOperationIdentity, + executingAtMs: number, + ): Promise { + validTime(executingAtMs, "executingAtMs"); + return this.#transition(identity, "prepared", "executing", (current) => ({ + ...current.receipt, + state: "executing", + executingAtMs, + })); + } + async reserveIndeterminate( + identity: AgentOperationIdentity, + reason: AgentOperationIndeterminateReason, + reservedAtMs: number, + ): Promise> { + this.#open(); + validateIdentity(identity); + validIndeterminateReason(reason); + validTime(reservedAtMs, "reservedAtMs"); + return Promise.resolve( + this.#transaction(() => { + const row = this.#select( + identity.fence.sessionId, + identity.operationId, + ); + if (!row) throw new AgentOperationNotFoundError(); + const current = decodeRow(row, this.#maxRowBytes); + if (!sameIdentity(current, identity)) + this.#conflict(identity, "transition_identity_mismatch"); + if (current.quarantineReason) + throw new AgentOperationConflictError( + "agent operation is quarantined", + ); + if (current.receipt.state !== "executing") + throw new AgentOperationTransitionError( + current.receipt.state, + "indeterminate", + ); + if (current.terminalReservation) + return structuredClone(current.terminalReservation); + if (reservedAtMs < current.receipt.executingAtMs!) + throw new TypeError("reservation precedes execution"); + const reservation = Object.freeze({ + reservationId: `reservation:${randomBytes(32).toString("hex")}`, + reason, + reservedAtMs, + }); + const receipt = { + ...current.receipt, + terminalReservation: reservation, + } as AgentOperationReceiptV1; + const receiptJson = encodeReceipt(receipt, this.#maxRowBytes); + rowLimit( + canonicalDescriptorJson(current), + receiptJson, + this.#maxRowBytes, + ); + const result = this.#db + .query( + "UPDATE agent_operation_receipts SET terminal_reservation_id=?, terminal_reservation_reason=?, terminal_reserved_at=?, receipt_json=? WHERE session_id=? AND operation_id=? AND state='executing' AND terminal_reservation_id IS NULL", + ) + .run( + reservation.reservationId, + reservation.reason, + reservation.reservedAtMs, + receiptJson, + identity.fence.sessionId, + identity.operationId, + ); + if (result.changes !== 1) + throw new AgentOperationTerminalReservedError(); + return reservation; + }), + ); + } + async settle( + identity: AgentOperationIdentity, + settlement: AgentOperationSettlement, + ): Promise { + validTime(settlement.completedAtMs, "completedAtMs"); + const expectedOutcomeCode = + settlement.outcome.code ?? + (settlement.outcome.status === "succeeded" + ? "ok" + : settlement.outcome.status === "cancelled" + ? "cancelled" + : "operation_failed"); + if (settlement.kernelTerminal.outcomeCode !== expectedOutcomeCode) + throw new TypeError("settled terminal outcome contradicts settlement"); + return this.#transition( + identity, + "executing", + "settled", + (current) => ({ + ...current.receipt, + state: "settled", + completedAtMs: settlement.completedAtMs, + outcome: settlement.outcome, + transcriptRefs: settlement.transcriptRefs, + kernelTerminal: settlement.kernelTerminal, + providerRef: { + adapterId: current.adapterId, + adapterVersion: current.adapterVersion, + ...(settlement.providerRequestRef === undefined + ? {} + : { requestId: settlement.providerRequestRef }), + ...(settlement.providerResponseRef === undefined + ? {} + : { responseId: settlement.providerResponseRef }), + }, + }), + { rejectTerminalReservation: true }, + ); + } + async markIndeterminate( + identity: AgentOperationIdentity, + reservation: Readonly, + completedAtMs: number, + kernelTerminal: AgentOperationReceiptV1["kernelTerminal"], + ): Promise { + validTime(completedAtMs, "completedAtMs"); + if (!kernelTerminal) + throw new TypeError("indeterminate terminal evidence is required"); + validateTerminalReservation(reservation); + if (completedAtMs < reservation.reservedAtMs) + throw new TypeError("completion precedes terminal reservation"); + if (kernelTerminal.outcomeCode !== reservation.reason) + throw new TypeError("indeterminate terminal outcome contradicts reason"); + return this.#transition( + identity, + "executing", + "indeterminate", + (current) => { + const { terminalReservation: _reservation, ...receipt } = + current.receipt; + return { + ...receipt, + state: "indeterminate", + completedAtMs, + transcriptRefs: kernelTerminal.transcriptRefs, + kernelTerminal, + errorCode: reservation.reason, + }; + }, + { terminalReservation: reservation }, + ); + } + async getExact( + identity: AgentOperationIdentity, + ): Promise { + this.#open(); + validateIdentity(identity); + const row = this.#select(identity.fence.sessionId, identity.operationId); + if (!row) return undefined; + const record = decodeRow(row, this.#maxRowBytes); + if (!sameIdentity(record, identity)) { + this.#transaction(() => + this.#conflict(identity, "get_identity_mismatch"), + ); + } + return record; + } + async queryAuthorized( + queryInput: AgentOperationAuthorizedQuery, + ): Promise { + this.#open(); + // Snapshot strict data properties before touching SQLite. This rejects + // accessors, Proxies with inconsistent descriptors and inherited fields. + const query = decodeAgentOperationAuthorizedQuery(queryInput); + const row = this.#select(query.fence.sessionId, query.operationId); + if (!row) return undefined; + // Decode and cross-check the complete durable row before authorization so + // corruption never becomes a partial or requester-dependent read. + const record = decodeRow(row, this.#maxRowBytes); + if (record.quarantineReason || !matchesAuthorizedQuery(record, query)) + return undefined; + return structuredClone(record); + } + async scanActive(): Promise { + this.#open(); + return ( + this.#db + .query( + "SELECT * FROM agent_operation_receipts WHERE state IN ('prepared','executing') AND quarantine_reason IS NULL ORDER BY ordinal", + ) + .all() as Row[] + ).map((row) => decodeRow(row, this.#maxRowBytes)); + } + async retireSession(sessionId: string): Promise { + this.#open(); + validText(sessionId, "sessionId"); + return this.#transaction(() => { + const active = this.#db + .query<{ count: number }, [string]>( + "SELECT COUNT(*) AS count FROM agent_operation_receipts WHERE session_id=? AND state IN ('prepared','executing')", + ) + .get(sessionId)!.count; + if (active) throw new AgentOperationSessionActiveError(); + return this.#db + .query("DELETE FROM agent_operation_receipts WHERE session_id=?") + .run(sessionId).changes; + }); + } + async deleteSession(sessionId: string): Promise { + this.#open(); + validText(sessionId, "sessionId"); + return this.#transaction( + () => + this.#db + .query("DELETE FROM agent_operation_receipts WHERE session_id=?") + .run(sessionId).changes, + ); + } + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.#db.close(); + secureFiles(this.#path); + } + + #transition( + identity: AgentOperationIdentity, + expected: "prepared" | "executing", + next: "executing" | "settled" | "indeterminate", + update: (record: AgentOperationRecord) => AgentOperationReceiptV1, + options: { + rejectTerminalReservation?: boolean; + terminalReservation?: Readonly; + } = {}, + ): Promise { + this.#open(); + validateIdentity(identity); + return Promise.resolve( + this.#transaction(() => { + const row = this.#select( + identity.fence.sessionId, + identity.operationId, + ); + if (!row) throw new AgentOperationNotFoundError(); + const current = decodeRow(row, this.#maxRowBytes); + if (!sameIdentity(current, identity)) + this.#conflict(identity, "transition_identity_mismatch"); + if (current.quarantineReason) + throw new AgentOperationConflictError( + "agent operation is quarantined", + ); + if (current.receipt.state !== expected) + throw new AgentOperationTransitionError(current.receipt.state, next); + if (options.rejectTerminalReservation && current.terminalReservation) + throw new AgentOperationTerminalReservedError(); + if ( + options.terminalReservation && + !sameTerminalReservation( + current.terminalReservation, + options.terminalReservation, + ) + ) + throw new AgentOperationTerminalReservedError(); + const receipt = update(current); + const receiptJson = encodeReceipt(receipt, this.#maxRowBytes); + rowLimit( + canonicalDescriptorJson(current), + receiptJson, + this.#maxRowBytes, + ); + const result = this.#db + .query( + "UPDATE agent_operation_receipts SET state=?, executing_at=?, completed_at=?, terminal_reservation_id=?, terminal_reservation_reason=?, terminal_reserved_at=?, receipt_json=? WHERE session_id=? AND operation_id=? AND state=?", + ) + .run( + receipt.state, + receipt.executingAtMs ?? null, + receipt.completedAtMs ?? null, + next === "executing" + ? (current.terminalReservation?.reservationId ?? null) + : null, + next === "executing" + ? (current.terminalReservation?.reason ?? null) + : null, + next === "executing" + ? (current.terminalReservation?.reservedAtMs ?? null) + : null, + receiptJson, + identity.fence.sessionId, + identity.operationId, + expected, + ); + if (result.changes !== 1) + throw new AgentOperationTransitionError(current.receipt.state, next); + if (next === "executing") return { ...current, receipt }; + const { terminalReservation: _reservation, ...withoutReservation } = + current; + return { ...withoutReservation, receipt }; + }), + ); + } + #conflict( + identity: AgentOperationIdentity, + reason: AgentOperationQuarantineReason, + ): never { + this.#db + .query( + "UPDATE agent_operation_receipts SET quarantine_reason=COALESCE(quarantine_reason, ?) WHERE session_id=? AND operation_id=?", + ) + .run(reason, identity.fence.sessionId, identity.operationId); + throw new CommittedConflict(); + } + #select(sessionId: string, operationId: string): Row | null { + return this.#db + .query( + "SELECT * FROM agent_operation_receipts WHERE session_id=? AND operation_id=?", + ) + .get(sessionId, operationId) as Row | null; + } + #transaction(fn: () => T): T { + this.#db.exec("BEGIN IMMEDIATE;"); + try { + const result = fn(); + this.#db.exec("COMMIT;"); + return result; + } catch (error) { + if (error instanceof CommittedConflict) { + this.#db.exec("COMMIT;"); + throw new AgentOperationConflictError(); + } + try { + this.#db.exec("ROLLBACK;"); + } catch {} + throw error; + } + } + #open(): void { + if (this.#closed) throw new Error("Agent operation ledger is closed"); + } +} + +function decodeRow(row: Row, max: number): AgentOperationRecord { + for (const field of [row.descriptor_json, row.receipt_json]) + if (typeof field !== "string" || encoder.encode(field).byteLength > max) + throw new Error("corrupt Agent operation ledger row"); + let descriptorValue: unknown; + let receiptValue: unknown; + try { + descriptorValue = JSON.parse(row.descriptor_json as string); + receiptValue = JSON.parse(row.receipt_json as string); + } catch { + throw new Error("corrupt Agent operation ledger JSON"); + } + const descriptor = decodeAgentOperationDescriptorV1(descriptorValue); + const receipt = decodeAgentOperationReceiptV1(receiptValue); + if ( + !descriptor || + !receipt || + typeof row.session_id !== "string" || + typeof row.operation_id !== "string" || + typeof row.kind !== "string" || + typeof row.run_id !== "string" || + typeof row.turn_id !== "string" || + !Number.isSafeInteger(row.generation) || + typeof row.plan_hash !== "string" || + typeof row.authority_hash !== "string" || + typeof row.descriptor_digest !== "string" || + typeof row.payload_digest !== "string" || + typeof row.adapter_id !== "string" || + typeof row.adapter_version !== "string" || + typeof row.state !== "string" || + receipt.state !== row.state || + descriptor.kind !== row.kind + ) + throw new Error("corrupt Agent operation ledger row"); + const identity = { + operationId: row.operation_id, + kind: row.kind, + fence: { + sessionId: row.session_id, + runId: row.run_id, + turnId: row.turn_id, + generation: row.generation, + }, + planHash: row.plan_hash, + authorityHash: row.authority_hash, + supervisorEpoch: receipt.actorIdentity.supervisorEpoch, + hostId: receipt.actorIdentity.hostId, + hostGeneration: receipt.actorIdentity.hostGeneration, + hostIncarnation: receipt.actorIdentity.hostIncarnation, + transcriptAnchor: receipt.actorIdentity.transcriptAnchor, + ...(receipt.kind === "mcp" + ? { toolUseEntryId: receipt.actorIdentity.toolUseEntryId } + : {}), + descriptor, + descriptorDigest: row.descriptor_digest, + payloadDigest: row.payload_digest, + adapterId: row.adapter_id, + adapterVersion: row.adapter_version, + } as AgentOperationIdentity; + validateIdentity(identity); + const hasTerminalReservation = + row.terminal_reservation_id !== null || + row.terminal_reservation_reason !== null || + row.terminal_reserved_at !== null; + let terminalReservation: AgentOperationTerminalReservation | undefined; + if (hasTerminalReservation) { + terminalReservation = { + reservationId: row.terminal_reservation_id as string, + reason: + row.terminal_reservation_reason as AgentOperationIndeterminateReason, + reservedAtMs: row.terminal_reserved_at as number, + }; + try { + validateTerminalReservation(terminalReservation); + } catch { + throw new Error("corrupt Agent operation terminal reservation"); + } + if ( + receipt.state !== "executing" || + receipt.executingAtMs === undefined || + terminalReservation.reservedAtMs < receipt.executingAtMs + ) + throw new Error("contradictory Agent operation terminal reservation"); + } + if ( + receipt.operationId !== identity.operationId || + receipt.kind !== identity.kind || + receipt.planHash !== identity.planHash || + receipt.authorityHash !== identity.authorityHash || + receipt.descriptorDigest !== identity.descriptorDigest || + receipt.payloadDigest !== identity.payloadDigest || + receipt.providerRef.adapterId !== identity.adapterId || + receipt.providerRef.adapterVersion !== identity.adapterVersion || + JSON.stringify(receipt.fence) !== JSON.stringify(identity.fence) || + row.accepted_at !== receipt.acceptedAtMs || + row.executing_at !== (receipt.executingAtMs ?? null) || + row.completed_at !== (receipt.completedAtMs ?? null) || + JSON.stringify(receipt.terminalReservation ?? null) !== + JSON.stringify(terminalReservation ?? null) || + !Number.isSafeInteger(row.ordinal) || + (row.ordinal as number) < 1 || + row.descriptor_json !== canonicalDescriptorJson(identity) || + row.receipt_json !== encodeReceipt(receipt, max) + ) + throw new Error("tampered Agent operation ledger receipt"); + if ( + row.quarantine_reason !== null && + row.quarantine_reason !== "claim_identity_mismatch" && + row.quarantine_reason !== "get_identity_mismatch" && + row.quarantine_reason !== "transition_identity_mismatch" + ) + throw new Error("corrupt Agent operation ledger quarantine"); + const quarantineReason = + row.quarantine_reason as AgentOperationQuarantineReason | null; + return { + ...identity, + receipt, + ...(terminalReservation === undefined ? {} : { terminalReservation }), + ...(quarantineReason === null ? {} : { quarantineReason }), + }; +} +function canonicalDescriptorJson(identity: AgentOperationIdentity): string { + const bytes = serializeAgentOperationDescriptorV1(identity.descriptor); + const recomputed = `sha256:${createHash("sha256") + .update(`${AGENT_OPERATION_DESCRIPTOR_DIGEST_DOMAIN}\0`) + .update(bytes) + .digest("hex")}`; + if (recomputed !== identity.descriptorDigest) + throw new TypeError( + "descriptor digest does not match canonical descriptor", + ); + return new TextDecoder().decode(bytes); +} +function matchesAuthorizedQuery( + record: AgentOperationRecord, + query: AgentOperationAuthorizedQuery, +): boolean { + return ( + record.operationId === query.operationId && + record.kind === query.kind && + record.fence.sessionId === query.fence.sessionId && + record.fence.runId === query.fence.runId && + record.fence.turnId === query.fence.turnId && + record.fence.generation === query.fence.generation && + record.descriptorDigest === query.descriptorDigest && + (query.payloadDigest === undefined || + record.payloadDigest === query.payloadDigest) && + record.planHash === query.authority.planHash && + record.authorityHash === query.authority.authorityHash && + record.supervisorEpoch === query.authority.supervisorEpoch && + record.hostId === query.authority.hostId && + record.hostGeneration === query.authority.hostGeneration && + record.hostIncarnation === query.authority.hostIncarnation + ); +} +function sameIdentity( + a: AgentOperationIdentity, + b: AgentOperationIdentity, +): boolean { + return ( + a.operationId === b.operationId && + a.kind === b.kind && + a.fence.sessionId === b.fence.sessionId && + a.fence.runId === b.fence.runId && + a.fence.turnId === b.fence.turnId && + a.fence.generation === b.fence.generation && + a.planHash === b.planHash && + a.authorityHash === b.authorityHash && + a.supervisorEpoch === b.supervisorEpoch && + a.hostId === b.hostId && + a.hostGeneration === b.hostGeneration && + a.hostIncarnation === b.hostIncarnation && + JSON.stringify(a.transcriptAnchor) === JSON.stringify(b.transcriptAnchor) && + a.toolUseEntryId === b.toolUseEntryId && + a.descriptorDigest === b.descriptorDigest && + a.payloadDigest === b.payloadDigest && + a.adapterId === b.adapterId && + a.adapterVersion === b.adapterVersion && + canonicalDescriptorJson(a) === canonicalDescriptorJson(b) + ); +} +function validateIdentity(v: AgentOperationIdentity): void { + for (const [name, value] of [ + ["operationId", v.operationId], + ["sessionId", v.fence.sessionId], + ["runId", v.fence.runId], + ["turnId", v.fence.turnId], + ["hostId", v.hostId], + ["hostIncarnation", v.hostIncarnation], + ["adapterId", v.adapterId], + ["adapterVersion", v.adapterVersion], + ] as const) + validText(value, name); + if (!Number.isSafeInteger(v.fence.generation) || v.fence.generation < 0) + throw new TypeError("invalid generation"); + positive(v.supervisorEpoch, "supervisorEpoch"); + positive(v.hostGeneration, "hostGeneration"); + if ( + !Number.isSafeInteger(v.transcriptAnchor.throughChangeSeq) || + v.transcriptAnchor.throughChangeSeq < 0 || + !/^sha256:[a-f0-9]{64}$/.test(v.transcriptAnchor.digest) || + !Array.isArray(v.transcriptAnchor.entryIds) || + v.transcriptAnchor.entryIds.length > 512 || + new Set(v.transcriptAnchor.entryIds).size !== + v.transcriptAnchor.entryIds.length + ) + throw new TypeError("invalid transcript anchor"); + for (const entryId of v.transcriptAnchor.entryIds) + validText(entryId, "transcriptAnchor.entryId"); + if ( + (v.kind === "model" && v.toolUseEntryId !== undefined) || + (v.kind === "mcp" && + (!v.toolUseEntryId || + v.descriptor.kind !== "mcp" || + v.descriptor.toolUseEntryId !== v.toolUseEntryId)) + ) + throw new TypeError("invalid tool-use identity"); + if ( + v.kind === "model" && + (v.descriptor.kind !== "model" || + JSON.stringify(v.descriptor.transcript) !== + JSON.stringify(v.transcriptAnchor)) + ) + throw new TypeError("model transcript anchor mismatch"); + if (v.descriptor.kind !== v.kind) + throw new TypeError("descriptor kind mismatch"); + canonicalDescriptorJson(v); + for (const value of [ + v.planHash, + v.authorityHash, + v.descriptorDigest, + v.payloadDigest, + ]) + if (!/^sha256:[a-f0-9]{64}$/.test(value)) + throw new TypeError("invalid digest"); +} +function validIndeterminateReason( + value: unknown, +): asserts value is AgentOperationIndeterminateReason { + if ( + value !== "reconciliation_unsupported" && + value !== "reconciliation_failed" && + value !== "ambiguous_completion" && + value !== "identity_mismatch" && + value !== "cancellation_ambiguous" && + value !== "timeout_ambiguous" && + value !== "disconnect_ambiguous" + ) + throw new TypeError("invalid indeterminate reason"); +} +function validateTerminalReservation( + value: Readonly, +): void { + if (!/^reservation:[a-f0-9]{64}$/.test(value.reservationId)) + throw new TypeError("invalid terminal reservation ID"); + validIndeterminateReason(value.reason); + validTime(value.reservedAtMs, "reservedAtMs"); +} +function sameTerminalReservation( + a: Readonly | undefined, + b: Readonly, +): boolean { + return ( + a?.reservationId === b.reservationId && + a.reason === b.reason && + a.reservedAtMs === b.reservedAtMs + ); +} +function validText(v: string, name: string): void { + if ( + !v || + encoder.encode(v).byteLength > MAX_ID_BYTES || + v.trim() !== v || + /[\u0000-\u001f\u007f]/u.test(v) + ) + throw new TypeError(`invalid ${name}`); +} +function validTime(v: number, name: string): void { + if (!Number.isSafeInteger(v) || v < 0) throw new TypeError(`invalid ${name}`); +} +function positive(v: number, name: string): number { + if (!Number.isSafeInteger(v) || v < 1) throw new TypeError(`invalid ${name}`); + return v; +} +function encodeReceipt(v: AgentOperationReceiptV1, max: number): string { + const bytes = serializeAgentOperationReceiptV1(v); + if (bytes.byteLength > max) throw new AgentOperationLedgerFullError(); + return new TextDecoder().decode(bytes); +} +function rowLimit(...args: [string, string, number]): void { + const [a, b, max] = args; + if (encoder.encode(a).byteLength + encoder.encode(b).byteLength > max) + throw new AgentOperationLedgerFullError(); +} + +function normalizeSql(sql: string | null): string { + return (sql ?? "").replace(/\s+/gu, "").replace(/;+$/u, "").toLowerCase(); +} +function initialize(db: Database): void { + const version = db + .query<{ user_version: number }, []>("PRAGMA user_version") + .get()!.user_version; + if (version !== 0 && version !== SCHEMA_VERSION) + throw new Error(`unsupported Agent operation ledger schema: ${version}`); + if (version === 0) { + const tables = db + .query<{ name: string }, []>( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + ) + .all(); + if (tables.length) + throw new Error( + "unversioned Agent operation ledger schema is unsupported", + ); + db.exec("BEGIN IMMEDIATE;"); + try { + db.exec( + `${TABLE_SQL}; ${ACTIVE_INDEX_SQL}; PRAGMA user_version=${SCHEMA_VERSION};`, + ); + db.exec("COMMIT;"); + } catch (error) { + db.exec("ROLLBACK;"); + throw error; + } + } + const tables = db + .query<{ name: string }, []>( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .all() + .map((v) => v.name); + if (tables.join() !== "agent_operation_receipts") + throw new Error("Agent operation ledger schema tables do not match"); + const tableList = db + .query< + { name: string; type: string; ncol: number; wr: number; strict: number }, + [] + >("PRAGMA table_list") + .all() + .find((row) => row.name === "agent_operation_receipts"); + if ( + !tableList || + tableList.type !== "table" || + tableList.ncol !== 23 || + tableList.wr !== 0 || + tableList.strict !== 1 + ) + throw new Error("Agent operation ledger table is not exact STRICT schema"); + const columns = db + .query< + { + name: string; + type: string; + notnull: number; + dflt_value: unknown; + pk: number; + hidden: number; + }, + [] + >("PRAGMA table_xinfo('agent_operation_receipts')") + .all(); + const names = [ + "session_id", + "operation_id", + "kind", + "run_id", + "turn_id", + "generation", + "plan_hash", + "authority_hash", + "descriptor_digest", + "payload_digest", + "adapter_id", + "adapter_version", + "state", + "accepted_at", + "executing_at", + "completed_at", + "terminal_reservation_id", + "terminal_reservation_reason", + "terminal_reserved_at", + "descriptor_json", + "receipt_json", + "quarantine_reason", + "ordinal", + ]; + const nullable = new Set([ + "executing_at", + "completed_at", + "terminal_reservation_id", + "terminal_reservation_reason", + "terminal_reserved_at", + "quarantine_reason", + ]); + if ( + columns.length !== names.length || + columns.some( + (column, index) => + column.name !== names[index] || + column.type !== + ([ + "generation", + "accepted_at", + "executing_at", + "completed_at", + "terminal_reserved_at", + "ordinal", + ].includes(column.name) + ? "INTEGER" + : "TEXT") || + column.notnull !== (nullable.has(column.name) ? 0 : 1) || + column.dflt_value !== null || + column.hidden !== 0 || + column.pk !== + (column.name === "session_id" + ? 1 + : column.name === "operation_id" + ? 2 + : 0), + ) + ) + throw new Error("Agent operation ledger schema columns do not match"); + const objects = db + .query<{ name: string; type: string; sql: string | null }, []>( + "SELECT name,type,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name", + ) + .all(); + if ( + objects.length !== 2 || + objects[0]?.name !== "agent_operation_active" || + objects[0]?.type !== "index" || + normalizeSql(objects[0].sql) !== normalizeSql(ACTIVE_INDEX_SQL) || + objects[1]?.name !== "agent_operation_receipts" || + objects[1]?.type !== "table" || + normalizeSql(objects[1].sql) !== normalizeSql(TABLE_SQL) + ) + throw new Error("Agent operation ledger schema objects do not match"); + const check = db + .query<{ integrity_check: string }, []>("PRAGMA integrity_check") + .get(); + if (check?.integrity_check !== "ok") + throw new Error("Agent operation ledger integrity check failed"); +} +function preparePrivatePath(path: string): void { + const parent = dirname(path); + mkdirSync(parent, { recursive: true, mode: 0o700 }); + const root = parse(parent).root; + let current = root; + for (const part of parent + .slice(root.length) + .split(/[\\/]+/u) + .filter(Boolean)) { + current = resolve(current, part); + const stat = lstatSync(current); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error( + `unsafe Agent operation ledger path component: ${current}`, + ); + } + chmodSync(parent, 0o700); + const fd = openSync( + path, + constants.O_CREAT | constants.O_RDWR | (constants.O_NOFOLLOW ?? 0), + 0o600, + ); + try { + if (!fstatSync(fd).isFile()) + throw new Error("Agent operation ledger path is not a regular file"); + chmodSync(path, 0o600); + } finally { + closeSync(fd); + } +} +function preflightSidecars(path: string): void { + for (const file of [`${path}-wal`, `${path}-shm`]) { + const stat = lstatSync(file, { throwIfNoEntry: false }); + if (!stat) continue; + if (stat.isSymbolicLink() || !stat.isFile()) + throw new Error(`unsafe Agent operation ledger SQLite file: ${file}`); + const fd = openSync(file, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0)); + try { + if (!fstatSync(fd).isFile()) + throw new Error(`unsafe Agent operation ledger SQLite file: ${file}`); + chmodSync(file, 0o600); + } finally { + closeSync(fd); + } + } +} +function secureFiles(path: string): void { + for (const file of [path, `${path}-wal`, `${path}-shm`]) { + const stat = lstatSync(file, { throwIfNoEntry: false }); + if (!stat) continue; + if (stat.isSymbolicLink() || !stat.isFile()) + throw new Error(`unsafe Agent operation ledger SQLite file: ${file}`); + chmodSync(file, 0o600); + } +} diff --git a/packages/core/opensession-server/src/server/agent-operation/stream-journal.test.ts b/packages/core/opensession-server/src/server/agent-operation/stream-journal.test.ts new file mode 100644 index 0000000000..8f6403ec48 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/stream-journal.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { + AgentOperationStreamJournal, + AgentOperationStreamRecoveryRequiredError, +} from "./stream-journal"; + +describe("AgentOperationStreamJournal", () => { + test("publication remains blocked until exact cumulative acknowledgement", async () => { + const journal = new AgentOperationStreamJournal(); + let done = false; + const publishing = journal.publish({ delta: "safe" }).then(() => { + done = true; + }); + await Promise.resolve(); + expect(done).toBe(false); + const iterator = journal.replay(0)[Symbol.asyncIterator](); + expect(new TextDecoder().decode((await iterator.next()).value)).toBe( + '{"delta":"safe"}\n', + ); + journal.acknowledge(1); + await publishing; + expect(done).toBe(true); + await journal.close(); + expect((await iterator.next()).done).toBe(true); + }); + test("rejects cursor gaps after acknowledged frames are retired", async () => { + const journal = new AgentOperationStreamJournal(); + const publishing = journal.publish({ delta: "x" }); + journal.acknowledge(1); + await publishing; + expect(() => journal.replay(0)).toThrow( + AgentOperationStreamRecoveryRequiredError, + ); + }); + test("enforces 48 KiB chunks without retaining content in diagnostics", async () => { + const journal = new AgentOperationStreamJournal(); + await expect( + journal.publish({ secret: "x".repeat(49 * 1024) }), + ).rejects.toBeInstanceOf(AgentOperationStreamRecoveryRequiredError); + expect( + JSON.stringify({ bytes: journal.bytes, frames: journal.frameCount }), + ).not.toContain("secret"); + }); + test("enforces bounded capacity until real consumption ACK retires frames", async () => { + const journal = new AgentOperationStreamJournal(); + const blocked = Array.from({ length: 128 }, (_, index) => + journal.publish({ index }), + ); + expect(journal.frameCount).toBe(128); + await expect(journal.publish({ overflow: true })).rejects.toThrow( + "journal is full", + ); + const iterator = journal.replay(0)[Symbol.asyncIterator](); + for (let index = 0; index < 128; index++) + expect((await iterator.next()).done).toBe(false); + journal.acknowledge(128); + await Promise.all(blocked); + expect(journal.frameCount).toBe(0); + await journal.close(); + expect((await iterator.next()).done).toBe(true); + }); + + test("close waits for consumption ACK while failure is bounded and redacts diagnostics", async () => { + const journal = new AgentOperationStreamJournal(); + const publishing = journal.publish({ delta: "x" }); + let closed = false; + const closing = journal.close().then(() => { closed = true; }); + await Promise.resolve(); + expect(closed).toBe(false); + journal.acknowledge(1); + await Promise.all([publishing, closing]); + expect(closed).toBe(true); + + const failed = new AgentOperationStreamJournal(); + const blocked = failed.publish({ secret: "payload-secret" }); + await failed.fail(new Error("credential-secret")); + await expect(blocked).rejects.toThrow("operation stream is closed"); + const iterator = failed.replay(0)[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ done: false }); + try { + await iterator.next(); + throw new Error("expected replay failure"); + } catch (error) { + expect(String(error)).not.toContain("credential-secret"); + expect(String(error)).not.toContain("payload-secret"); + } + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/stream-journal.ts b/packages/core/opensession-server/src/server/agent-operation/stream-journal.ts new file mode 100644 index 0000000000..b43d364ade --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/stream-journal.ts @@ -0,0 +1,139 @@ +import type { AgentGatewayLiveEventSink } from "./gateway"; +const MAX_FRAMES = 128, + MAX_BYTES = 1024 * 1024, + MAX_CHUNK = 48 * 1024; +export class AgentOperationStreamRecoveryRequiredError extends Error { + constructor(message = "operation stream recovery required") { + super(message); + this.name = "AgentOperationStreamRecoveryRequiredError"; + } +} +export class AgentOperationStreamClosedError extends Error { + constructor() { + super("operation stream is closed"); + this.name = "AgentOperationStreamClosedError"; + } +} +type Frame = { seq: number; bytes: Uint8Array }; +type Waiter = { + through: number; + resolve: () => void; + reject: (e: unknown) => void; +}; +/** Memory-only bounded journal. Publication resolves only after cumulative Host ACK. */ +export class AgentOperationStreamJournal implements AgentGatewayLiveEventSink { + readonly #frames: Frame[] = []; + readonly #waiters: Waiter[] = []; + readonly #listeners = new Set<() => void>(); + readonly #drainers = new Set<() => void>(); + #next = 1; + #acked = 0; + #bytes = 0; + #closed = false; + #failure: unknown; + async publish(event: Readonly): Promise { + if (this.#closed) throw new AgentOperationStreamClosedError(); + const bytes = new TextEncoder().encode(`${JSON.stringify(event)}\n`); + if (bytes.byteLength > MAX_CHUNK) + throw new AgentOperationStreamRecoveryRequiredError( + "operation stream chunk is too large", + ); + if ( + this.#frames.length >= MAX_FRAMES || + this.#bytes + bytes.byteLength > MAX_BYTES + ) + throw new AgentOperationStreamRecoveryRequiredError( + "operation stream journal is full", + ); + const seq = this.#next++; + this.#frames.push({ seq, bytes }); + this.#bytes += bytes.byteLength; + this.#notify(); + await new Promise((resolve, reject) => + this.#waiters.push({ through: seq, resolve, reject }), + ); + } + acknowledge(through: number): void { + if ( + !Number.isSafeInteger(through) || + through < this.#acked || + through >= this.#next + ) + throw new AgentOperationStreamRecoveryRequiredError( + "invalid operation stream cursor", + ); + this.#acked = through; + while (this.#frames[0]?.seq <= through) + this.#bytes -= this.#frames.shift()!.bytes.byteLength; + for (let i = this.#waiters.length - 1; i >= 0; i--) { + const w = this.#waiters[i]!; + if (w.through <= through) { + this.#waiters.splice(i, 1); + w.resolve(); + } + } + this.#notify(); + if (!this.#waiters.length) { + for (const drain of this.#drainers) drain(); + this.#drainers.clear(); + } + } + replay(after: number): AsyncIterable { + if (!Number.isSafeInteger(after) || after < 0 || after >= this.#next) + throw new AgentOperationStreamRecoveryRequiredError( + "invalid operation stream cursor", + ); + const oldest = this.#frames[0]?.seq ?? this.#next; + if (after < oldest - 1) + throw new AgentOperationStreamRecoveryRequiredError( + "operation stream cursor is too old", + ); + const self = this; + return { + async *[Symbol.asyncIterator]() { + let cursor = after; + for (;;) { + const frame = self.#frames.find((f) => f.seq === cursor + 1); + if (frame) { + cursor = frame.seq; + yield frame.bytes.slice(); + continue; + } + if (self.#failure) throw self.#failure; + if (self.#closed) return; + await new Promise((resolve) => self.#listeners.add(resolve)); + } + }, + }; + } + async close(): Promise { + this.#closed = true; + this.#notify(); + if (this.#waiters.length) + await new Promise((resolve) => this.#drainers.add(resolve)); + return Object.freeze({ throughStreamSeq: this.#acked }); + } + async fail(_reason?: unknown): Promise { + this.#closed = true; + // The journal is transport-facing. Never retain or replay caller/provider + // diagnostics, which may contain payload or credential material. + this.#failure = new AgentOperationStreamClosedError(); + for (const w of this.#waiters.splice(0)) w.reject(this.#failure); + for (const drain of this.#drainers) drain(); + this.#drainers.clear(); + this.#notify(); + } + get bytes() { + return this.#bytes; + } + get frameCount() { + return this.#frames.length; + } + get acknowledgedThrough() { + return this.#acked; + } + #notify() { + for (const listener of this.#listeners) listener(); + this.#listeners.clear(); + } +} diff --git a/packages/core/opensession-server/src/server/agent-operation/transcript-facade.test.ts b/packages/core/opensession-server/src/server/agent-operation/transcript-facade.test.ts new file mode 100644 index 0000000000..37b4746e94 --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/transcript-facade.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentOperationOutcomeV1 } from "@tellahq/opensession-protocol/agent-operation"; +import { TranscriptStore, TRANSCRIPT_DESTINATION_MAX_ENTRIES } from "../transcript-store"; +import type { TranscriptEntry } from "../types"; +import type { AgentGatewayAdapterResult } from "./gateway"; +import type { AgentOperationIdentity, AgentOperationTerminalReservation } from "./ledger"; +import { + AgentOperationTranscriptFacade, + AgentTranscriptReservationAuthenticationError, + type AgentTranscriptRenderResult, +} from "./transcript-facade"; + +const digest = (c: string) => `sha256:${c.repeat(64)}` as const; +const freeze = (value: T): Readonly => Object.freeze(value); + +function identity(kind: "model" | "mcp" = "model"): AgentOperationIdentity { + const descriptor = kind === "model" + ? { version: 1, kind, model: "claude", maxOutputTokens: 100, inputDigest: digest("1") } + : { version: 1, kind, serverId: "server", toolName: "tool", argumentsDigest: digest("2") }; + return freeze({ + operationId: "operation-1", kind, + fence: freeze({ sessionId: "session-1", runId: "run-1", turnId: "turn-1", generation: 1 }), + planHash: digest("3"), authorityHash: digest("4"), supervisorEpoch: 1, + hostId: "host-1", hostGeneration: 1, hostIncarnation: "incarnation-1", + transcriptAnchor: freeze({ throughChangeSeq: 0, entryIds: freeze([] as string[]), digest: digest("5") }), + ...(kind === "mcp" ? { toolUseEntryId: "call-1" } : {}), + descriptor: freeze(descriptor) as unknown as AgentOperationIdentity["descriptor"], + descriptorDigest: digest("6"), payloadDigest: digest("7"), adapterId: "adapter", adapterVersion: "1", + }); +} + +const reservation = freeze({ + reservationId: `reservation:${"a".repeat(64)}`, + reason: "timeout_ambiguous" as const, + reservedAtMs: 10, +}); + +function adapter(outcome: AgentOperationOutcomeV1 = freeze({ status: "succeeded", code: "ok" }), transcript: unknown = freeze({ private: "provider-secret" })): AgentGatewayAdapterResult { + return freeze({ outcome: freeze(outcome), transcript, providerRequestRef: "provider-secret-request" }); +} + +function entry(id: string, type: TranscriptEntry["type"] = "assistant", content = "hello"): Readonly { + return freeze({ id, type, content, timestamp: "2026-08-23T00:00:00.000Z" }); +} + +function rendered(entries: readonly Readonly[], pending?: readonly string[]): AgentTranscriptRenderResult { + return freeze({ entries: freeze([...entries]), ...(pending === undefined ? {} : { pendingToolUseEntryIds: freeze([...pending]) }) }); +} + +function fixture(kind: "model" | "mcp" = "model") { + const dir = mkdtempSync(join(tmpdir(), "agent-transcript-facade-")); + const path = join(dir, "transcripts.db"); + const store = new TranscriptStore(path); + const who = identity(kind); + let renders = 0; + let commits = 0; + const renderer = async (): Promise => { + renders++; + return kind === "model" + ? rendered([entry("answer"), entry("pending", "tool_use", "call")], ["pending"]) + : rendered([entry("tool-result", "tool_result", "done")]); + }; + const facadeFor = ( + target: TranscriptStore, + authenticate: (identity: Readonly, reservation: Readonly) => Promise | undefined> = async (_i, r) => r, + ) => new AgentOperationTranscriptFacade({ + store: { + commitTranscriptDestinationAppendReceipt(input) { commits++; return target.commitTranscriptDestinationAppendReceipt(input); }, + validateAgentTranscriptReceiptRef(input) { return target.validateAgentTranscriptReceiptRef(input); }, + }, + render: renderer, + authenticateReservation: authenticate, + }); + return { dir, path, store, who, facade: facadeFor(store), facadeFor, counts: () => ({ renders, commits }) }; +} + +function cleanup(f: ReturnType) { + try { f.store.close(); } catch {} + rmSync(f.dir, { recursive: true, force: true }); +} + +describe("Agent operation transcript destination facade", () => { + test("renders model and MCP terminals with canonical receipts and exact kernel evidence", async () => { + const model = fixture("model"); + try { + const first = await model.facade.appendTerminal(model.who, adapter()); + expect(first.refs).toHaveLength(1); + expect(first.refs[0]?.entryIds).toEqual(["answer", "pending"]); + expect(first.kernelTerminal).toEqual({ + outputDigest: first.refs[0]?.requestDigest, + outcomeCode: "ok", + transcriptRefs: first.refs, + pendingToolUseEntryIds: ["pending"], + }); + expect(Object.isFrozen(first.refs)).toBe(true); + } finally { cleanup(model); } + + const mcp = fixture("mcp"); + try { + const terminal = await mcp.facade.appendTerminal(mcp.who, adapter(freeze({ status: "failed", code: "provider_error", outputDigest: digest("e") }))); + expect(terminal.kernelTerminal).toEqual({ outputDigest: digest("e"), outcomeCode: "provider_error", transcriptRefs: terminal.refs }); + expect("pendingToolUseEntryIds" in terminal.kernelTerminal).toBe(false); + } finally { cleanup(mcp); } + }); + + test("commits exactly once across retries and store restart", async () => { + const f = fixture(); + try { + const first = await f.facade.appendTerminal(f.who, adapter()); + const retry = await f.facade.appendTerminal(f.who, adapter()); + expect(retry).toEqual(first); + expect(f.counts()).toEqual({ renders: 1, commits: 1 }); + f.store.close(); + const reopened = new TranscriptStore(f.path); + const restarted = f.facadeFor(reopened); + const replay = await restarted.appendTerminal(f.who, adapter()); + expect(replay).toEqual(first); + expect(reopened.countEvents("session-1")).toBe(2); + expect(f.counts()).toEqual({ renders: 2, commits: 2 }); + reopened.close(); + } finally { cleanup(f); } + }); + + test("uses reservation-derived append identity and authenticates before any callback", async () => { + const f = fixture(); + try { + const good = await f.facade.appendIndeterminate(f.who, reservation, adapter(freeze({ status: "failed", code: reservation.reason }) as unknown as AgentOperationOutcomeV1)); + expect(good.refs[0]?.appendId).toMatch(/^agent-indeterminate:[a-f0-9]{64}$/); + expect(good.refs[0]?.appendId).not.toContain(f.who.operationId); + const forged = freeze({ ...reservation, reservationId: `reservation:${"b".repeat(64)}` }); + const before = f.counts(); + const rejecting = f.facadeFor(f.store, async () => undefined); + await expect(rejecting.appendIndeterminate(f.who, forged, adapter())).rejects.toBeInstanceOf(AgentTranscriptReservationAuthenticationError); + expect(f.counts()).toEqual(before); + } finally { cleanup(f); } + }); + + test("replays supplied proof before rendering and rejects anchor or append mismatch", async () => { + const f = fixture(); + try { + const first = await f.facade.appendTerminal(f.who, adapter()); + const freshFacade = f.facadeFor(f.store); + const replay = await freshFacade.appendTerminal(f.who, adapter(), { receipt: first.refs[0]!, pendingToolUseEntryIds: freeze(["pending"]) }); + expect(replay).toEqual(first); + expect(f.counts()).toEqual({ renders: 1, commits: 1 }); + await expect(freshFacade.appendTerminal(f.who, adapter(), { receipt: freeze({ ...first.refs[0]!, appendId: "forged" }), pendingToolUseEntryIds: freeze(["pending"]) })).rejects.toThrow(/identity mismatch/); + const wrongAnchor = freeze({ ...f.who, transcriptAnchor: freeze({ ...f.who.transcriptAnchor, digest: digest("9") }) }); + await expect(freshFacade.appendTerminal(wrongAnchor, adapter(), { receipt: first.refs[0]!, pendingToolUseEntryIds: freeze(["pending"]) })).rejects.toThrow(); + } finally { cleanup(f); } + }); + + test("keeps receipts valid after unrelated history and fails closed on referenced tamper, deletion, or order corruption", async () => { + const f = fixture(); + try { + const first = await f.facade.appendTerminal(f.who, adapter()); + f.store.commitTranscriptDestinationAppend({ + sessionId: "session-1", runId: "other-run", turnId: "other-turn", generation: 1, appendId: "unrelated", + entries: [{ ...entry("later"), content: "later" }], + }); + const fresh = f.facadeFor(f.store); + await expect(fresh.appendTerminal(f.who, adapter(), { receipt: first.refs[0]!, pendingToolUseEntryIds: freeze(["pending"]) })).resolves.toEqual(first); + + const db = new Database(f.path); + db.run("UPDATE transcript_events SET change_seq = change_seq + 50 WHERE session_id = ? AND uuid = ?", ["session-1", "answer"]); + db.close(); + await expect(fresh.appendTerminal(f.who, adapter(), { receipt: first.refs[0]!, pendingToolUseEntryIds: freeze(["pending"]) })).rejects.toThrow(); + + const f2 = fixture(); + try { + const receipt = (await f2.facade.appendTerminal(f2.who, adapter())).refs[0]!; + const deleteDb = new Database(f2.path); + deleteDb.run("DELETE FROM transcript_events WHERE session_id = ? AND uuid = ?", ["session-1", "answer"]); + deleteDb.close(); + await expect(f2.facadeFor(f2.store).appendTerminal(f2.who, adapter(), { receipt, pendingToolUseEntryIds: freeze(["pending"]) })).rejects.toThrow(); + } finally { cleanup(f2); } + } finally { cleanup(f); } + }); + + test("rejects duplicate IDs, mutable or over-bound renderer evidence without destination mutation", async () => { + const f = fixture(); + try { + const cases: AgentTranscriptRenderResult[] = [ + rendered([entry("same"), entry("same")], []), + freeze({ entries: [entry("mutable-array")] as readonly Readonly[], pendingToolUseEntryIds: freeze([]) }), + rendered(Array.from({ length: TRANSCRIPT_DESTINATION_MAX_ENTRIES + 1 }, (_, i) => entry(`e-${i}`)), []), + ]; + for (const evidence of cases) { + const facade = new AgentOperationTranscriptFacade({ store: f.store, render: async () => evidence, authenticateReservation: async (_i, r) => r }); + await expect(facade.appendTerminal(f.who, adapter())).rejects.toThrow(); + } + expect(f.store.countEvents("session-1")).toBe(0); + } finally { cleanup(f); } + }); + + test("never persists provider-private material", async () => { + const f = fixture(); + try { + await f.facade.appendTerminal(f.who, adapter(undefined, freeze({ apiKey: "sk-provider-secret", raw: "provider-secret-body" }))); + f.store.close(); + const bytes = await Bun.file(f.path).text(); + expect(bytes).not.toContain("sk-provider-secret"); + expect(bytes).not.toContain("provider-secret-body"); + expect(bytes).not.toContain("provider-secret-request"); + } finally { cleanup(f); } + }); +}); diff --git a/packages/core/opensession-server/src/server/agent-operation/transcript-facade.ts b/packages/core/opensession-server/src/server/agent-operation/transcript-facade.ts new file mode 100644 index 0000000000..bec0f842bb --- /dev/null +++ b/packages/core/opensession-server/src/server/agent-operation/transcript-facade.ts @@ -0,0 +1,275 @@ +import type { + AgentOperationDigest, + AgentOperationKernelTerminalV1, + AgentTranscriptReceiptRefV1, +} from "@tellahq/opensession-protocol/agent-operation"; +import type { TranscriptEntry } from "../types"; +import { + TRANSCRIPT_DESTINATION_MAX_BYTES, + TRANSCRIPT_DESTINATION_MAX_ENTRIES, + type TranscriptStore, +} from "../transcript-store"; +import type { AgentGatewayAdapterResult, AgentGatewayTranscriptTerminal } from "./gateway"; +import type { + AgentOperationIdentity, + AgentOperationTerminalReservation, +} from "./ledger"; + +const APPEND_ID_DOMAIN = "opensession.agent-transcript-append.v1\0"; +const MAX_PENDING_TOOL_USES = 64; + +export interface AgentTranscriptRenderResult { + readonly entries: readonly Readonly[]; + /** Required, including an empty array, for model operations; forbidden for MCP. */ + readonly pendingToolUseEntryIds?: readonly string[]; +} + +export interface AgentTranscriptReplayHint { + readonly receipt: Readonly; + readonly pendingToolUseEntryIds?: readonly string[]; +} + +export interface AgentOperationTranscriptFacadeOptions { + readonly store: Pick< + TranscriptStore, + "commitTranscriptDestinationAppendReceipt" | "validateAgentTranscriptReceiptRef" + >; + /** The only component permitted to turn provider material into transcript rows. */ + readonly render: ( + identity: Readonly, + result: Readonly, + ) => AgentTranscriptRenderResult | Promise; + /** Must return the canonical durable reservation, or undefined on any mismatch. */ + readonly authenticateReservation: ( + identity: Readonly, + reservation: Readonly, + ) => + | Readonly + | undefined + | Promise | undefined>; +} + +export class AgentTranscriptReservationAuthenticationError extends Error { + readonly code = "AGENT_TRANSCRIPT_RESERVATION_AUTHENTICATION_FAILED"; + constructor() { + super("Agent transcript terminal reservation is not authentic"); + this.name = "AgentTranscriptReservationAuthenticationError"; + } +} + +/** + * Production-unwired destination facade. It owns no process resources and has + * no import-time effects. Provider-private adapter material reaches only the + * injected renderer and is never included in receipts or errors. + */ +export class AgentOperationTranscriptFacade { + readonly #options: AgentOperationTranscriptFacadeOptions; + readonly #replays = new Map(); + + constructor(options: AgentOperationTranscriptFacadeOptions) { + this.#options = options; + } + + appendTerminal( + identity: Readonly, + result: Readonly, + replay?: Readonly, + ): Promise { + return this.#append(identity, result, terminalAppendId(identity), replay); + } + + async appendIndeterminate( + identity: Readonly, + reservation: Readonly, + result: Readonly, + replay?: Readonly, + ): Promise { + // Authentication deliberately precedes receipt lookup, rendering, and all + // destination callbacks so a forged reservation has no observable effect. + const authenticated = await this.#options.authenticateReservation( + identity, + reservation, + ); + if (!authenticated || !sameReservation(authenticated, reservation)) + throw new AgentTranscriptReservationAuthenticationError(); + return this.#append( + identity, + result, + reservationAppendId(authenticated.reservationId), + replay, + ); + } + + async #append( + identity: Readonly, + result: Readonly, + appendId: string, + replay?: Readonly, + ): Promise { + const existing = replay ?? this.#replays.get(appendId); + if (existing) { + const receipt = this.#validateReplay(identity, appendId, existing.receipt); + const pending = validatePending(identity.kind, existing.pendingToolUseEntryIds, receipt.entryIds); + return terminal(result, receipt, pending); + } + + const rendered = validateRendered( + identity.kind, + await this.#options.render(identity, result), + ); + const durable = this.#options.store.commitTranscriptDestinationAppendReceipt({ + sessionId: identity.fence.sessionId, + runId: identity.fence.runId, + turnId: identity.fence.turnId, + generation: identity.fence.generation, + transcriptAnchor: identity.transcriptAnchor, + appendId, + entries: rendered.entries as TranscriptEntry[], + }); + const receipt = this.#validateReplay(identity, appendId, { + appendId: durable.appendId, + entryIds: durable.entryIds, + firstSeq: durable.firstSeq, + lastSeq: durable.lastSeq, + throughChangeSeq: durable.throughChangeSeq, + requestDigest: durable.requestDigest, + }); + const pending = validatePending( + identity.kind, + rendered.pendingToolUseEntryIds, + receipt.entryIds, + ); + this.#replays.set(appendId, Object.freeze({ receipt, ...(pending === undefined ? {} : { pendingToolUseEntryIds: pending }) })); + return terminal(result, receipt, pending); + } + + #validateReplay( + identity: Readonly, + appendId: string, + candidate: Readonly, + ): AgentTranscriptReceiptRefV1 { + if (candidate.appendId !== appendId) + throw new TypeError("Agent transcript replay append identity mismatch"); + const canonical = this.#options.store.validateAgentTranscriptReceiptRef({ + sessionId: identity.fence.sessionId, + runId: identity.fence.runId, + turnId: identity.fence.turnId, + generation: identity.fence.generation, + transcriptAnchor: identity.transcriptAnchor, + receipt: candidate, + }); + if (!canonical) throw new TypeError("Agent transcript replay receipt is missing"); + return canonical; + } +} + +function terminal( + result: Readonly, + receipt: AgentTranscriptReceiptRefV1, + pending: readonly string[] | undefined, +): AgentGatewayTranscriptTerminal { + const refs = Object.freeze([receipt]); + const outputDigest = result.outcome.outputDigest ?? receipt.requestDigest; + const kernelTerminal: AgentOperationKernelTerminalV1 = Object.freeze({ + outputDigest, + outcomeCode: outcomeCode(result), + transcriptRefs: refs, + ...(pending === undefined ? {} : { pendingToolUseEntryIds: pending }), + }); + return Object.freeze({ refs, kernelTerminal }); +} + +function outcomeCode(result: Readonly): string { + return result.outcome.code ?? + (result.outcome.status === "succeeded" + ? "ok" + : result.outcome.status === "cancelled" + ? "cancelled" + : "operation_failed"); +} + +function validateRendered( + kind: AgentOperationIdentity["kind"], + rendered: AgentTranscriptRenderResult, +): AgentTranscriptRenderResult { + if (!rendered || typeof rendered !== "object" || !Object.isFrozen(rendered)) + throw new TypeError("Agent transcript renderer returned mutable evidence"); + if (!Array.isArray(rendered.entries) || !Object.isFrozen(rendered.entries) || rendered.entries.length < 1) + throw new TypeError("Agent transcript renderer returned invalid entries"); + if (rendered.entries.length > TRANSCRIPT_DESTINATION_MAX_ENTRIES) + throw new RangeError("Agent transcript renderer exceeded entry limit"); + if (rendered.entries.some((entry) => !entry || typeof entry !== "object" || !Object.isFrozen(entry))) + throw new TypeError("Agent transcript renderer returned mutable entries"); + if (new Set(rendered.entries.map((entry) => entry.id)).size !== rendered.entries.length) + throw new TypeError("Agent transcript renderer returned duplicate entry IDs"); + // Bound before invoking the store. JSON serialization also rejects cycles; + // the store remains authoritative for the complete strict entry schema. + let json: string; + try { json = JSON.stringify(rendered.entries); } catch { throw new TypeError("Agent transcript renderer returned invalid entries"); } + if (Buffer.byteLength(json) > TRANSCRIPT_DESTINATION_MAX_BYTES) + throw new RangeError("Agent transcript renderer exceeded byte limit"); + const pending = validatePending(kind, rendered.pendingToolUseEntryIds, rendered.entries.map((entry) => entry.id)); + return Object.freeze({ entries: rendered.entries, ...(pending === undefined ? {} : { pendingToolUseEntryIds: pending }) }); +} + +function validatePending( + kind: AgentOperationIdentity["kind"], + value: readonly string[] | undefined, + entryIds: readonly string[], +): readonly string[] | undefined { + if (kind === "mcp") { + if (value !== undefined) throw new TypeError("MCP transcript cannot carry pending tool uses"); + return undefined; + } + if (!Array.isArray(value) || !Object.isFrozen(value) || value.length > MAX_PENDING_TOOL_USES) + throw new TypeError("Model transcript pending tool uses are invalid"); + let prior = -1; + for (const id of value) { + if (typeof id !== "string" || id.length < 1 || id.length > 256) + throw new TypeError("Model transcript pending tool use ID is invalid"); + const index = entryIds.indexOf(id); + if (index <= prior) throw new TypeError("Model transcript pending tool uses are not ordered entries"); + prior = index; + } + return Object.freeze([...value]); +} + +function terminalAppendId(identity: Readonly): string { + return digestAppendId("terminal", canonicalIdentity(identity)); +} + +function reservationAppendId(reservationId: string): string { + if (typeof reservationId !== "string" || reservationId.length < 1) + throw new TypeError("Invalid Agent transcript reservation identity"); + return digestAppendId("indeterminate", reservationId); +} + +function digestAppendId(kind: string, material: string): string { + const digest = new Bun.CryptoHasher("sha256") + .update(APPEND_ID_DOMAIN) + .update(kind) + .update("\0") + .update(material) + .digest("hex"); + return `agent-${kind}:${digest}`; +} + +function canonicalIdentity(identity: Readonly): string { + // Descriptor and anchors are protocol-decoded plain immutable JSON. Sorting + // recursively avoids caller property insertion order influencing ownership. + return canonicalJson(identity); +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; +} + +function sameReservation( + a: Readonly, + b: Readonly, +): boolean { + return a.reservationId === b.reservationId && a.reason === b.reason && a.reservedAtMs === b.reservedAtMs; +} diff --git a/packages/core/opensession-server/src/server/security/transport/linux-peer-credentials.test.ts b/packages/core/opensession-server/src/server/security/transport/linux-peer-credentials.test.ts new file mode 100644 index 0000000000..49d700bca4 --- /dev/null +++ b/packages/core/opensession-server/src/server/security/transport/linux-peer-credentials.test.ts @@ -0,0 +1,409 @@ +import { chmod, lstat, mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { connect, createServer, type Socket } from "node:net"; +import { afterEach, describe, expect, test } from "bun:test"; +import { + createLinuxPeerCredentialVerifier, + createLinuxPeerCredentialVerifierFromBackend, + decodeLinuxUcred, + type LinuxPeerCredentialBackend, +} from "./linux-peer-credentials"; +import { + createLinuxUnixSocketPathLock, + createVerifiedUnixSocketServer, + doctorLinuxPeerCredentials, + isProvenStaleSocketConnectError, + type VerifiedAcceptedSocket, + removeProvenStaleUnixSocket, + validateUnixSocketParent, + validateUnixSocketPath, +} from "./unix-socket-security"; + +const uid = process.getuid!(); +const gid = process.getgid!(); +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { while (cleanups.length) await cleanups.pop()!(); }); + +async function secureTempDir(prefix: string): Promise { + const dir = await mkdtemp(join(homedir(), `.${prefix}`)); + await chmod(dir, 0o700); + return dir; +} + +async function listeningSocket(onConnection?: (socket: Socket) => void) { + const dir = await secureTempDir("os-peer-"); + const path = join(dir, "peer.sock"); + const server = createServer(onConnection); + await new Promise((resolve, reject) => server.listen(path, resolve).once("error", reject)); + cleanups.push(async () => { await new Promise((resolve) => server.close(() => resolve())); await rm(dir, { recursive: true, force: true }); }); + return { dir, path, server }; +} + +function dial(path: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect(path, () => resolve(socket)); + socket.once("error", reject); + }); +} + +function nextAccepted(server: ReturnType): Promise { + return new Promise((resolve) => server.once("connection", resolve)); +} + +describe("Linux peer credentials", () => { + test("requires the exact architecture-neutral struct ucred length", () => { + const bytes = new Uint8Array(12); + expect(() => decodeLinuxUcred(bytes, 11)).toThrow("11 bytes"); + expect(() => decodeLinuxUcred(bytes, 13)).toThrow("13 bytes"); + expect(() => decodeLinuxUcred(new Uint8Array(11), 12)).toThrow("expected 12"); + }); + + test("fails closed when Bun FFI is unavailable", async () => { + await expect(createLinuxPeerCredentialVerifier(async () => { throw new Error("injected import failure"); })).rejects.toThrow("FFI is unavailable"); + }); + + test("reads real current-process credentials and closes idempotently", async () => { + const { path, server } = await listeningSocket(); + const acceptedPromise = nextAccepted(server); + const client = await dial(path); + const accepted = await acceptedPromise; + const verifier = await createLinuxPeerCredentialVerifier(); + expect(verifier.verify(accepted, { uid, gid })).toMatchObject({ uid, gid, pid: process.pid }); + verifier.close(); + verifier.close(); + expect(() => verifier.verify(accepted, { uid })).toThrow("closed"); + client.destroy(); accepted.destroy(); + }); + + test("rejects wrong IDs, implicit root, malformed policy, closed socket, and backend errors", async () => { + const { path, server } = await listeningSocket(); + const acceptedPromise = nextAccepted(server); + const client = await dial(path); + const accepted = await acceptedPromise; + const verifier = await createLinuxPeerCredentialVerifier(); + expect(() => verifier.verify({} as Socket, { uid })).toThrow("Unsupported runtime"); + expect(() => verifier.verify(accepted, { uid: uid + 1 })).toThrow("UID"); + expect(() => verifier.verify(accepted, { uid, gid: gid + 1 })).toThrow("GID"); + expect(() => verifier.verify(accepted, { uid: 0 })).toThrow("explicit allowRoot"); + expect(() => verifier.verify(accepted, { uid: -1 })).toThrow("Malformed"); + expect(() => verifier.verify(accepted, { uid, processIdentity: "/usr/bin/agent-host" })).toThrow("evidence unavailable"); + accepted.destroy(); + expect(() => verifier.verify(accepted, { uid })).toThrow("open accepted socket"); + verifier.close(); client.destroy(); + + const backend: LinuxPeerCredentialBackend = { read() { throw new Error("injected getsockopt error"); }, close() {} }; + const injected = createLinuxPeerCredentialVerifierFromBackend(backend); + const pair = await listeningSocket(); + const serverSocketPromise = nextAccepted(pair.server); + const peer = await dial(pair.path); + const serverSocket = await serverSocketPromise; + expect(() => injected.verify(serverSocket, { uid })).toThrow("injected getsockopt"); + injected.close(); peer.destroy(); serverSocket.destroy(); + }); + + test("gate verifies before first byte, destroys rejection, and never transfers identity", async () => { + const dir = await secureTempDir("os-verified-peer-"); + const path = join(dir, "peer.sock"); + const verifier = await createLinuxPeerCredentialVerifier(); + const events: string[] = []; + const identities: symbol[] = []; + const acceptedSockets: VerifiedAcceptedSocket[] = []; + const gate = createVerifiedUnixSocketServer(verifier, { uid }, (accepted) => { + const { socket, socketIdentity } = accepted; + acceptedSockets.push(accepted); + events.push("verified"); identities.push(socketIdentity); + socket.once("data", () => events.push("data")); + socket.resume(); + }); + const pathLock = await createLinuxUnixSocketPathLock(path, { uid, gid, mode: 0o700 }); + const listenOptions = { + path, + parentPolicy: { uid, gid, mode: 0o700 }, + socketPolicy: { uid, gid, mode: 0o600 }, + pathLock, + } as const; + const starting = gate.listen(listenOptions); + await expect(gate.listen(listenOptions)).rejects.toThrow("while starting"); + await starting; + expect((await lstat(path)).mode & 0o7777).toBe(0o600); + cleanups.push(async () => { await gate.closeAndDrain(); await rm(dir, { recursive: true, force: true }); }); + const first = connect(path); first.write("before-connect"); + await Bun.sleep(30); + const second = connect(path); second.write("second"); + await Bun.sleep(30); + expect(events).toEqual(["verified", "data", "verified", "data"]); + expect(identities[0]).not.toBe(identities[1]); + first.destroy(); + acceptedSockets[0].socket.destroy(); + await new Promise((resolve) => acceptedSockets[0].socket.once("close", resolve)); + expect(() => acceptedSockets[0].assertCurrent()).toThrow("no longer current"); + second.destroy(); await gate.closeAndDrain(); verifier.close(); + + const rejectedDir = await secureTempDir("os-rejected-peer-"); + const rejectedPath = join(rejectedDir, "peer.sock"); + const rejecting = await createLinuxPeerCredentialVerifier(); + const rejectedServer = createVerifiedUnixSocketServer(rejecting, { uid: uid + 1 }, () => { throw new Error("must not run"); }); + const rejectedLock = await createLinuxUnixSocketPathLock(rejectedPath, { uid, gid, mode: 0o700 }); + await rejectedServer.listen({ + path: rejectedPath, + parentPolicy: { uid, gid, mode: 0o700 }, + socketPolicy: { uid, gid, mode: 0o600 }, + pathLock: rejectedLock, + }); + cleanups.push(async () => { await rejectedServer.closeAndDrain(); await rm(rejectedDir, { recursive: true, force: true }); }); + const rejected = await dial(rejectedPath); + await new Promise((resolve) => rejected.once("close", () => resolve())); + expect(rejected.destroyed).toBe(true); + rejecting.close(); + }); + + test("rejects mocked root credentials before admission", async () => { + const dir = await secureTempDir("os-peer-root-reject-"); + const path = join(dir, "peer.sock"); + let backendReads = 0; + let admissions = 0; + const verifier = createLinuxPeerCredentialVerifierFromBackend({ + read(fd) { + expect(fd).toBeGreaterThanOrEqual(0); + backendReads++; + return { pid: process.pid, uid: 0, gid: 0 }; + }, + close() {}, + }); + const server = createVerifiedUnixSocketServer(verifier, { uid }, () => { admissions++; }); + const pathLock = await createLinuxUnixSocketPathLock(path, { uid, gid, mode: 0o700 }); + await server.listen({ + path, + parentPolicy: { uid, gid, mode: 0o700 }, + socketPolicy: { uid, gid, mode: 0o600 }, + pathLock, + }); + const client = await dial(path); + await new Promise((resolve) => client.once("close", resolve)); + expect(backendReads).toBe(1); + expect(admissions).toBe(0); + await server.closeAndDrain(); + verifier.close(); + await rm(dir, { recursive: true, force: true }); + }); + + test("adopts a multiprocess inherited listener without replacing its path", async () => { + const dir = await secureTempDir("os-peer-inherited-"); + const path = join(dir, "peer.sock"); + const helper = join(import.meta.dir, "testing/inherited-unix-socket-child.ts"); + const orchestratorSource = ` +import os, socket, subprocess, sys +path, bun, helper, uid = sys.argv[1:] +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +sock.bind(path) +os.chmod(path, 0o600) +sock.listen(16) +env = dict(os.environ, TEST_INHERITED_FD=str(sock.fileno()), TEST_EXPECTED_UID=uid) +child = subprocess.Popen([bun, helper], pass_fds=(sock.fileno(),), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) +sock.close() +line = child.stdout.readline() +if line: + print(line, end="", flush=True) +else: + print(child.stderr.read(), file=sys.stderr) +sys.stdin.buffer.read() +child.terminate() +child.wait(timeout=10) +`; + const orchestrator = Bun.spawn(["python3", "-c", orchestratorSource, path, process.execPath, helper, String(uid)], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + const ready = await orchestrator.stdout.getReader().read(); + expect(new TextDecoder().decode(ready.value)).toContain("ready"); + const before = await lstat(path); + const client = await dial(path); + const response = await new Promise((resolve, reject) => { + let value = ""; + client.on("data", (chunk) => { value += chunk.toString(); }); + client.once("end", () => resolve(value)); + client.once("error", reject); + }); + expect(response).toBe(`verified:${uid}`); + const whileListening = await lstat(path); + expect([whileListening.dev, whileListening.ino, whileListening.mode & 0o7777]).toEqual([before.dev, before.ino, 0o600]); + orchestrator.stdin.end(); + expect(await orchestrator.exited).toBe(0); + const after = await lstat(path); + expect([after.dev, after.ino, after.mode & 0o7777]).toEqual([before.dev, before.ino, 0o600]); + await rm(dir, { recursive: true, force: true }); + }); + + test("can require inherited-FD-only composition and rejects root policy or malformed descriptors", async () => { + const verifier = createLinuxPeerCredentialVerifierFromBackend({ + read() { return { pid: process.pid, uid, gid }; }, + close() {}, + }); + const inheritedOnly = createVerifiedUnixSocketServer( + verifier, + { uid }, + () => {}, + undefined, + { listenerMode: "inherited-fd-only" }, + ); + await expect(inheritedOnly.listen({ + path: "/must-not-bind.sock", + parentPolicy: { uid, gid, mode: 0o700 }, + socketPolicy: { uid, gid, mode: 0o600 }, + pathLock: undefined as never, + })).rejects.toThrow("requires an inherited listener"); + await inheritedOnly.closeAndDrain(); + + const malformedDescriptor = createVerifiedUnixSocketServer(verifier, { uid }, () => {}); + await expect(malformedDescriptor.listen({ inheritedFd: 2 })).rejects.toThrow("Malformed inherited"); + await malformedDescriptor.closeAndDrain(); + + const tcp = createServer(); + await new Promise((resolve) => tcp.listen(0, "127.0.0.1", resolve)); + const tcpFd = (tcp as unknown as { _handle: { fd: number } })._handle.fd; + const wrongDomain = createVerifiedUnixSocketServer(verifier, { uid }, () => {}); + await expect(wrongDomain.listen({ inheritedFd: tcpFd })).rejects.toThrow("not Unix-domain"); + await wrongDomain.closeAndDrain(); + await new Promise((resolve) => tcp.close(() => resolve())); + + const rootPolicy = createVerifiedUnixSocketServer(verifier, { uid: 0, allowRoot: true }, () => {}); + await expect(rootPolicy.listen({ inheritedFd: 3 })).rejects.toThrow("non-root"); + await rootPolicy.closeAndDrain(); + verifier.close(); + }); + + test("drains physical sockets even when an accepted handler never settles", async () => { + const dir = await secureTempDir("os-peer-drain-"); + const path = join(dir, "peer.sock"); + const verifier = await createLinuxPeerCredentialVerifier(); + const server = createVerifiedUnixSocketServer(verifier, { uid }, () => new Promise(() => {})); + const pathLock = await createLinuxUnixSocketPathLock(path, { uid, gid, mode: 0o700 }); + await server.listen({ + path, + parentPolicy: { uid, gid, mode: 0o700 }, + socketPolicy: { uid, gid, mode: 0o600 }, + pathLock, + }); + const client = await dial(path); + await server.closeAndDrain(20); + expect(pathLock.closed).toBe(true); + client.destroy(); verifier.close(); + await rm(dir, { recursive: true, force: true }); + }); + + test("rejects a real TCP socket before SO_PEERCRED authorization", async () => { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + cleanups.push(() => new Promise((resolve) => server.close(() => resolve()))); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing TCP address"); + const acceptedPromise = nextAccepted(server); + const client = await new Promise((resolve, reject) => { + const socket = connect(address.port, "127.0.0.1", () => resolve(socket)); + socket.once("error", reject); + }); + const accepted = await acceptedPromise; + const verifier = await createLinuxPeerCredentialVerifier(); + expect(() => verifier.verify(accepted, { uid })).toThrow("not Unix-domain"); + verifier.close(); client.destroy(); accepted.destroy(); + }); + + test("doctor proves runtime without exposing credentials and drains verifier", async () => { + const { path, server } = await listeningSocket(); + const acceptedPromise = nextAccepted(server); + const client = await dial(path); const accepted = await acceptedPromise; + const report = await doctorLinuxPeerCredentials(accepted, uid, createLinuxPeerCredentialVerifier); + expect(report.ok).toBe(true); + expect(Object.keys(report).sort()).toEqual(["expectedUid", "ok", "platform", "runtime"]); + const rootReport = await doctorLinuxPeerCredentials(accepted, 0, createLinuxPeerCredentialVerifier); + expect(rootReport).toMatchObject({ ok: false, expectedUid: 0, reason: "Root peer policy requires explicit allowRoot" }); + client.destroy(); accepted.destroy(); + }); +}); + +describe("Unix socket paths", () => { + test("validates exact parent/socket ownership and mode; rejects symlinks and mode mismatches", async () => { + const { dir, path } = await listeningSocket(); + await chmod(dir, 0o700); await chmod(path, 0o600); + await validateUnixSocketParent(dir, { uid, gid, mode: 0o700 }); + await validateUnixSocketPath(path, { uid, gid, mode: 0o600 }); + await expect(validateUnixSocketPath(path, { uid, gid, mode: 0o666 })).rejects.toThrow("mode"); + const link = `${path}.link`; await symlink(path, link); + await expect(validateUnixSocketPath(link, { uid, gid, mode: 0o600 })).rejects.toThrow("symlink"); + + const unsafeAncestor = join(dir, "unsafe"); + const protectedLeaf = join(unsafeAncestor, "leaf"); + await mkdir(unsafeAncestor, { mode: 0o700 }); + await chmod(unsafeAncestor, 0o770); + await mkdir(protectedLeaf, { mode: 0o700 }); + await expect(validateUnixSocketParent(protectedLeaf, { uid, gid, mode: 0o700 })).rejects.toThrow("writable by an untrusted principal"); + }); + + test("serializes stale removal and bind with a crash-safe path lock", async () => { + const dir = await secureTempDir("os-peer-lock-"); + cleanups.push(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, "peer.sock"); + const first = await createLinuxUnixSocketPathLock(path, { uid, gid, mode: 0o700 }); + await expect(createLinuxUnixSocketPathLock(path, { uid, gid, mode: 0o700 })).rejects.toThrow("Unable to acquire"); + await first.close(); + await first.close(); + const replacement = await createLinuxUnixSocketPathLock(path, { uid, gid, mode: 0o700 }); + await replacement.close(); + }); + + test("accepts only ECONNREFUSED as stale-socket proof", () => { + expect(isProvenStaleSocketConnectError({ code: "ECONNREFUSED" })).toBe(true); + expect(isProvenStaleSocketConnectError({ code: "EACCES" })).toBe(false); + expect(isProvenStaleSocketConnectError(new Error("resource pressure"))).toBe(false); + }); + + test("atomically removes only a proven stale socket inode", async () => { + const dir = await secureTempDir("os-peer-stale-"); + cleanups.push(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, "stale.sock"); + const child = Bun.spawn([process.execPath, "-e", `const {createServer}=require('node:net');const s=createServer();s.listen(${JSON.stringify(path)},()=>console.log('ready'))`], { stdout: "pipe" }); + const reader = child.stdout.getReader(); + await reader.read(); + child.kill("SIGKILL"); + await child.exited; + await chmod(path, 0o600); + const before = await lstat(path); + const staleLock = await createLinuxUnixSocketPathLock(path, { uid, gid, mode: 0o700 }); + await removeProvenStaleUnixSocket( + path, + { uid, gid, mode: 0o600 }, + { uid, gid, mode: 0o700 }, + staleLock, + ); + await staleLock.close(); + await expect(lstat(path)).rejects.toThrow(); + expect(before.isSocket()).toBe(true); + + const notSocketPath = join(dir, "not-socket"); + await mkdir(notSocketPath, { mode: 0o700 }); + const notSocketLock = await createLinuxUnixSocketPathLock(notSocketPath, { uid, gid, mode: 0o700 }); + await expect(removeProvenStaleUnixSocket( + notSocketPath, + { uid, gid, mode: 0o700 }, + { uid, gid, mode: 0o700 }, + notSocketLock, + )).rejects.toThrow("file type"); + await notSocketLock.close(); + + const activePath = join(dir, "active.sock"); + const active = createServer(); + await new Promise((resolve) => active.listen(activePath, resolve)); + await chmod(activePath, 0o600); + const activeLock = await createLinuxUnixSocketPathLock(activePath, { uid, gid, mode: 0o700 }); + await expect(removeProvenStaleUnixSocket( + activePath, + { uid, gid, mode: 0o600 }, + { uid, gid, mode: 0o700 }, + activeLock, + )).rejects.toThrow("active"); + await activeLock.close(); + await new Promise((resolve) => active.close(() => resolve())); + }); +}); diff --git a/packages/core/opensession-server/src/server/security/transport/linux-peer-credentials.ts b/packages/core/opensession-server/src/server/security/transport/linux-peer-credentials.ts new file mode 100644 index 0000000000..39f867a297 --- /dev/null +++ b/packages/core/opensession-server/src/server/security/transport/linux-peer-credentials.ts @@ -0,0 +1,152 @@ +import type { Socket } from "node:net"; +import { acceptedSocketDescriptor, sameAcceptedSocketDescriptor } from "./node-socket-fd"; + +const UCredBytes = 12; +const SockaddrStorageBytes = 128; +// Linux UAPI values. They are deliberately private to this linux-gated module. +const SOL_SOCKET = 1; +const SO_PEERCRED = 17; +const AF_UNIX = 1; +const nativeLittleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1; + +export interface LinuxPeerCredentials { + readonly pid: number; + readonly uid: number; + readonly gid: number; +} + +export interface PeerCredentialPolicy { + readonly uid: number; + readonly gid?: number; + readonly allowRoot?: boolean; + /** Optional evidence must come from a pidfd-backed, race-free provider. */ + readonly processIdentity?: string; +} + +export interface LinuxPeerCredentialBackend { + read(fd: number): LinuxPeerCredentials; + /** Must bind evidence to the same process through pidfd or an equivalent kernel handle. */ + readProcessIdentity?(fd: number, pid: number): string | undefined; + close(): void; +} + +export interface VerifiedPeer extends LinuxPeerCredentials { + readonly processIdentity?: string; +} + +export interface LinuxPeerCredentialVerifier { + verify(socket: Socket, policy: PeerCredentialPolicy): VerifiedPeer; + close(): void; + readonly closed: boolean; +} + +function validId(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 0xffff_ffff; +} + +function validatePolicy(policy: PeerCredentialPolicy): void { + if (!policy || typeof policy !== "object" || !validId(policy.uid) || + (policy.gid !== undefined && !validId(policy.gid)) || + (policy.allowRoot !== undefined && typeof policy.allowRoot !== "boolean") || + (policy.processIdentity !== undefined && + (typeof policy.processIdentity !== "string" || policy.processIdentity.length < 1 || policy.processIdentity.length > 512))) + throw new Error("Malformed peer credential policy"); + if (policy.uid === 0 && policy.allowRoot !== true) + throw new Error("Root peer policy requires explicit allowRoot"); +} + +export function decodeLinuxUcred(bytes: Uint8Array, returnedLength: number): LinuxPeerCredentials { + if (!Number.isSafeInteger(returnedLength) || returnedLength !== UCredBytes || bytes.byteLength !== UCredBytes) + throw new Error(`getsockopt(SO_PEERCRED) returned ${returnedLength} bytes, expected ${UCredBytes}`); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { + pid: view.getInt32(0, nativeLittleEndian), + uid: view.getUint32(4, nativeLittleEndian), + gid: view.getUint32(8, nativeLittleEndian), + }; +} + +function validateCredentials(value: LinuxPeerCredentials): void { + if (!Number.isSafeInteger(value.pid) || value.pid < 1 || value.pid > 0x7fff_ffff || + !validId(value.uid) || !validId(value.gid)) + throw new Error("Kernel returned malformed peer credentials"); +} + +export function createLinuxPeerCredentialVerifierFromBackend( + backend: LinuxPeerCredentialBackend, +): LinuxPeerCredentialVerifier { + if (process.platform !== "linux") throw new Error("Linux peer credentials are supported only on linux"); + let closed = false; + return { + get closed() { return closed; }, + verify(socket, policy) { + if (closed) throw new Error("Peer credential verifier is closed"); + validatePolicy(policy); + const before = acceptedSocketDescriptor(socket); + const credentials = backend.read(before.fd); + const after = acceptedSocketDescriptor(socket); + if (!sameAcceptedSocketDescriptor(before, after)) + throw new Error("Peer socket identity changed during credential verification"); + validateCredentials(credentials); + if (credentials.uid === 0 && policy.allowRoot !== true) throw new Error("Root peer rejected"); + if (credentials.uid !== policy.uid) throw new Error("Peer UID rejected"); + if (policy.gid !== undefined && credentials.gid !== policy.gid) throw new Error("Peer GID rejected"); + let processIdentity: string | undefined; + if (policy.processIdentity !== undefined) { + processIdentity = backend.readProcessIdentity?.(after.fd, credentials.pid); + if (!processIdentity || processIdentity !== policy.processIdentity) + throw new Error("Peer process identity evidence unavailable or rejected"); + if (!sameAcceptedSocketDescriptor(acceptedSocketDescriptor(socket), after)) + throw new Error("Peer socket identity changed during process identity verification"); + } + return Object.freeze({ ...credentials, ...(processIdentity ? { processIdentity } : {}) }); + }, + close() { + if (closed) return; + closed = true; + backend.close(); + }, + }; +} + +/** Explicitly loads libc. Importing this module never opens a dynamic library. */ +export async function createLinuxPeerCredentialVerifier( + loadFfi: () => Promise = () => import("bun:ffi"), +): Promise { + if (process.platform !== "linux") throw new Error("Linux peer credentials are supported only on linux"); + let ffi: typeof import("bun:ffi"); + try { + ffi = await loadFfi(); + } catch (error) { + throw new Error("Bun FFI is unavailable; peer credentials cannot be verified", { cause: error }); + } + let library; + try { + library = ffi.dlopen("libc.so.6", { + getsockopt: { args: ["int", "int", "int", "ptr", "ptr"], returns: "int" }, + getpeername: { args: ["int", "ptr", "ptr"], returns: "int" }, + } as const); + } catch (error) { + throw new Error("Unable to load Linux libc peer credential functions", { cause: error }); + } + const backend: LinuxPeerCredentialBackend = { + read(fd) { + const address = new Uint8Array(SockaddrStorageBytes); + const addressLength = new Uint32Array([SockaddrStorageBytes]); + if (library.symbols.getpeername(fd, ffi.ptr(address), ffi.ptr(addressLength)) !== 0) + throw new Error("getpeername failed for accepted socket"); + if (addressLength[0] < 2 || addressLength[0] > SockaddrStorageBytes) + throw new Error("getpeername returned malformed address length"); + const family = new DataView(address.buffer).getUint16(0, nativeLittleEndian); + if (family !== AF_UNIX) throw new Error("Peer socket is not Unix-domain"); + + const bytes = new Uint8Array(UCredBytes); + const length = new Uint32Array([UCredBytes]); + if (library.symbols.getsockopt(fd, SOL_SOCKET, SO_PEERCRED, ffi.ptr(bytes), ffi.ptr(length)) !== 0) + throw new Error("getsockopt(SO_PEERCRED) failed"); + return decodeLinuxUcred(bytes, length[0]); + }, + close() { library.close(); }, + }; + return createLinuxPeerCredentialVerifierFromBackend(backend); +} diff --git a/packages/core/opensession-server/src/server/security/transport/node-socket-fd.ts b/packages/core/opensession-server/src/server/security/transport/node-socket-fd.ts new file mode 100644 index 0000000000..ac1d0721e9 --- /dev/null +++ b/packages/core/opensession-server/src/server/security/transport/node-socket-fd.ts @@ -0,0 +1,30 @@ +import { fstatSync } from "node:fs"; +import type { Socket } from "node:net"; + +export interface AcceptedSocketDescriptor { + readonly fd: number; + readonly dev: number | bigint; + readonly ino: number | bigint; +} + +/** The only adapter allowed to inspect Bun's private node:net handle. */ +export function acceptedSocketDescriptor(socket: Socket): AcceptedSocketDescriptor { + if (!socket || typeof socket !== "object" || socket.destroyed || socket.pending) + throw new Error("Peer socket is not an open accepted socket"); + const candidate = socket as Socket & { _handle?: { fd?: unknown } }; + const fd = candidate._handle?.fd; + if (!Number.isSafeInteger(fd) || (fd as number) < 0) + throw new Error("Unsupported runtime: accepted node:net socket has no valid _handle.fd"); + let stat; + try { + stat = fstatSync(fd as number); + } catch (error) { + throw new Error("Accepted socket fd is closed or unavailable", { cause: error }); + } + if (!stat.isSocket()) throw new Error("Accepted socket fd does not identify a socket"); + return Object.freeze({ fd: fd as number, dev: stat.dev, ino: stat.ino }); +} + +export function sameAcceptedSocketDescriptor(left: AcceptedSocketDescriptor, right: AcceptedSocketDescriptor): boolean { + return left.fd === right.fd && left.dev === right.dev && left.ino === right.ino; +} diff --git a/packages/core/opensession-server/src/server/security/transport/testing/inherited-unix-socket-child.ts b/packages/core/opensession-server/src/server/security/transport/testing/inherited-unix-socket-child.ts new file mode 100644 index 0000000000..da983d659b --- /dev/null +++ b/packages/core/opensession-server/src/server/security/transport/testing/inherited-unix-socket-child.ts @@ -0,0 +1,25 @@ +import { createLinuxPeerCredentialVerifier } from "../linux-peer-credentials"; +import { createVerifiedUnixSocketServer } from "../unix-socket-security"; + +const inheritedFd = Number(process.env.TEST_INHERITED_FD); +const expectedUid = Number(process.env.TEST_EXPECTED_UID); +const verifier = await createLinuxPeerCredentialVerifier(); +const server = createVerifiedUnixSocketServer( + verifier, + { uid: expectedUid }, + ({ socket, peer }) => { + socket.end(`verified:${peer.uid}`); + }, + (error) => console.error(error.message), + { listenerMode: "inherited-fd-only" }, +); + +await server.listen({ inheritedFd }); +console.log("ready"); + +process.once("SIGTERM", () => { + void server.closeAndDrain().finally(() => { + verifier.close(); + process.exit(0); + }); +}); diff --git a/packages/core/opensession-server/src/server/security/transport/unix-socket-security.ts b/packages/core/opensession-server/src/server/security/transport/unix-socket-security.ts new file mode 100644 index 0000000000..313ff37e07 --- /dev/null +++ b/packages/core/opensession-server/src/server/security/transport/unix-socket-security.ts @@ -0,0 +1,464 @@ +import { constants as fsConstants } from "node:fs"; +import { chmod, lstat, open, rename, unlink } from "node:fs/promises"; +import { dirname, isAbsolute, parse, resolve, sep } from "node:path"; +import { connect, createServer, type Server, type Socket } from "node:net"; +import type { LinuxPeerCredentialVerifier, PeerCredentialPolicy, VerifiedPeer } from "./linux-peer-credentials"; + +export interface UnixPathPolicy { + readonly uid: number; + readonly gid?: number; + /** Exact permission bits, for example 0o700 or 0o600. */ + readonly mode: number; +} + +export interface UnixSocketPathLock { + readonly path: string; + readonly closed: boolean; + close(): Promise; +} + +const livePathLocks = new WeakMap(); + +function assertLivePathLock(lock: UnixSocketPathLock, path: string): void { + if (!lock || lock.closed || livePathLocks.get(lock) !== path) + throw new Error("A live exclusive lock for the exact Unix socket path is required"); +} + +export interface VerifiedAcceptedSocket { + readonly socket: Socket; + /** Throws after this exact physical socket closes or loses its binding. */ + readonly peer: VerifiedPeer; + /** Unique per physical accepted Socket object. Audit/fencing metadata only. */ + readonly socketIdentity: symbol; + assertCurrent(): VerifiedPeer; +} + +function validPathPolicy(policy: UnixPathPolicy): void { + if (!policy || !Number.isSafeInteger(policy.uid) || policy.uid < 0 || policy.uid > 0xffff_ffff || + (policy.gid !== undefined && (!Number.isSafeInteger(policy.gid) || policy.gid < 0 || policy.gid > 0xffff_ffff)) || + !Number.isSafeInteger(policy.mode) || policy.mode < 0 || policy.mode > 0o7777) + throw new Error("Malformed Unix socket path policy"); +} + +function requireCurrentProcessOwner(policy: UnixPathPolicy): void { + const currentUid = process.getuid?.(); + if (!Number.isSafeInteger(currentUid) || policy.uid !== currentUid) + throw new Error("Unix socket mutation policy must name the current numeric UID"); +} + +function assertMetadata(stat: Awaited>, policy: UnixPathPolicy, kind: "directory" | "socket") { + if (kind === "directory" ? !stat.isDirectory() : !stat.isSocket()) + throw new Error(`Unix ${kind} path has wrong file type`); + if (stat.isSymbolicLink()) throw new Error(`Unix ${kind} path must not be a symlink`); + if (stat.uid !== policy.uid || (policy.gid !== undefined && stat.gid !== policy.gid)) + throw new Error(`Unix ${kind} path owner rejected`); + if ((Number(stat.mode) & 0o7777) !== policy.mode) throw new Error(`Unix ${kind} path mode rejected`); +} + +async function assertProtectedPathComponents(path: string, policy: UnixPathPolicy): Promise { + if (!isAbsolute(path) || resolve(path) !== path) throw new Error("Unix socket path must be absolute and normalized"); + const root = parse(path).root; + const pieces = path.slice(root.length).split(sep).filter(Boolean); + let current = root; + for (const piece of pieces) { + current = resolve(current, piece); + const stat = await lstat(current); + if (stat.isSymbolicLink()) throw new Error(`Unix path contains symlink component: ${current}`); + if (stat.uid !== 0 && stat.uid !== policy.uid) + throw new Error(`Unix path component has an untrusted owner: ${current}`); + if ((Number(stat.mode) & 0o022) !== 0) + throw new Error(`Unix path component is writable by an untrusted principal: ${current}`); + } +} + +export async function validateUnixSocketParent(path: string, policy: UnixPathPolicy): Promise { + validPathPolicy(policy); + await assertProtectedPathComponents(path, policy); + assertMetadata(await lstat(path), policy, "directory"); +} + +export async function validateUnixSocketPath(path: string, policy: UnixPathPolicy): Promise { + validPathPolicy(policy); + await assertProtectedPathComponents(path, policy); + assertMetadata(await lstat(path), policy, "socket"); +} + +/** Acquires a crash-safe advisory lock retained across stale removal and bind. */ +export async function createLinuxUnixSocketPathLock( + path: string, + parentPolicy: UnixPathPolicy, +): Promise { + if (process.platform !== "linux") throw new Error("Unix socket path locks are supported only on linux"); + validPathPolicy(parentPolicy); + requireCurrentProcessOwner(parentPolicy); + await validateUnixSocketParent(dirname(path), parentPolicy); + const lockPath = `${path}.lock`; + const handle = await open(lockPath, fsConstants.O_CREAT | fsConstants.O_RDWR | fsConstants.O_NOFOLLOW, 0o600); + let library: { symbols: { flock(fd: number, operation: number): number }; close(): void } | undefined; + try { + await handle.chmod(0o600); + const [descriptor, lockStat] = await Promise.all([lstat(lockPath), handle.stat()]); + if (!descriptor.isFile() || descriptor.isSymbolicLink() || descriptor.uid !== parentPolicy.uid || + (Number(descriptor.mode) & 0o7777) !== 0o600 || descriptor.dev !== lockStat.dev || descriptor.ino !== lockStat.ino) + throw new Error("Unix socket lock file identity, owner, or mode rejected"); + const ffi = await import("bun:ffi"); + library = ffi.dlopen("libc.so.6", { + flock: { args: ["int", "int"], returns: "int" }, + } as const); + // Linux LOCK_EX | LOCK_NB. Values stay private to this linux-gated helper. + if (library.symbols.flock(handle.fd, 2 | 4) !== 0) + throw new Error("Unix socket path lock is already held"); + let closed = false; + const lock: UnixSocketPathLock = Object.freeze({ + path, + get closed() { return closed; }, + async close() { + if (closed) return; + closed = true; + livePathLocks.delete(lock); + try { library!.symbols.flock(handle.fd, 8); } finally { + await handle.close(); + library!.close(); + } + }, + }); + livePathLocks.set(lock, path); + return lock; + } catch (error) { + await handle.close().catch(() => {}); + library?.close(); + throw new Error("Unable to acquire Unix socket path lock", { cause: error }); + } +} + +/** + * Removes only the exact stale socket inode inspected by this call. It first + * proves the parent and every ancestor are protected, then uses an atomic + * rename so a later path replacement is never unlinked. + */ +export function isProvenStaleSocketConnectError(error: unknown): boolean { + return !!error && typeof error === "object" && "code" in error && error.code === "ECONNREFUSED"; +} + +export async function removeProvenStaleUnixSocket( + path: string, + policy: UnixPathPolicy, + parentPolicy: UnixPathPolicy, + lock: UnixSocketPathLock, +): Promise { + assertLivePathLock(lock, path); + validPathPolicy(policy); + validPathPolicy(parentPolicy); + requireCurrentProcessOwner(policy); + requireCurrentProcessOwner(parentPolicy); + await validateUnixSocketParent(dirname(path), parentPolicy); + const original = await lstat(path); + assertMetadata(original, policy, "socket"); + await new Promise((resolveStale, rejectUnproven) => { + const probe = connect(path); + const timer = setTimeout(() => { + probe.destroy(); + rejectUnproven(new Error("Unix socket stale probe timed out")); + }, 250); + timer.unref?.(); + probe.once("connect", () => { + clearTimeout(timer); + probe.destroy(); + rejectUnproven(new Error("Refusing to remove an active Unix socket")); + }); + probe.once("error", (error) => { + clearTimeout(timer); + if (isProvenStaleSocketConnectError(error)) resolveStale(); + else rejectUnproven(new Error("Unix socket staleness was not proven", { cause: error })); + }); + }); + const afterProbe = await lstat(path); + if (afterProbe.dev !== original.dev || afterProbe.ino !== original.ino) + throw new Error("Unix socket identity changed during stale probe"); + const quarantine = `${path}.stale-${process.pid}-${crypto.randomUUID()}`; + await rename(path, quarantine); + const moved = await lstat(quarantine); + if (moved.dev !== original.dev || moved.ino !== original.ino || !moved.isSocket() || moved.uid !== original.uid) { + throw new Error(`Stale Unix socket identity changed during atomic removal; retained at ${quarantine}`); + } + await unlink(quarantine); +} + +/** + * Installs a fail-closed accepted-socket gate. The socket is paused and verified + * before user code can attach protocol readers or allocate session state. + */ +export interface VerifiedUnixSocketGate { + close(): void; + closeAndDrain(): Promise; +} + +function installVerifiedUnixSocketGate( + server: Server, + verifier: LinuxPeerCredentialVerifier, + policy: PeerCredentialPolicy, + accept: (accepted: VerifiedAcceptedSocket) => void | Promise, +): VerifiedUnixSocketGate { + if (server.listenerCount("connection") !== 0) + throw new Error("Verified Unix socket gate must be the first connection listener"); + const identities = new WeakMap(); + const sockets = new Set(); + const closeWaiters = new Set>(); + let closed = false; + const onConnection = (socket: Socket) => { + socket.pause(); + // Untrusted peers can race a reset with rejection. Consume ordinary socket + // errors so an unauthorized connection cannot raise an uncaught exception. + socket.on("error", () => {}); + if (closed) { socket.destroy(); return; } + sockets.add(socket); + const closeWaiter = new Promise((resolve) => socket.once("close", () => { + sockets.delete(socket); + identities.delete(socket); + resolve(); + })); + closeWaiters.add(closeWaiter); + void closeWaiter.finally(() => closeWaiters.delete(closeWaiter)); + const identity = Symbol("accepted-unix-socket"); + identities.set(socket, identity); + let peer: VerifiedPeer; + try { + peer = verifier.verify(socket, policy); + if (socket.destroyed || identities.get(socket) !== identity) + throw new Error("Accepted socket changed before admission"); + } catch { + socket.destroy(); + return; + } + const assertCurrent = () => { + if (socket.destroyed || identities.get(socket) !== identity) + throw new Error("Verified socket binding is no longer current"); + return peer; + }; + const accepted = Object.freeze({ + socket, + get peer() { return assertCurrent(); }, + socketIdentity: identity, + assertCurrent, + }); + let result: void | Promise; + try { result = accept(accepted); } + catch { socket.destroy(); return; } + void Promise.resolve(result).catch(() => { socket.destroy(); }); + }; + server.prependListener("connection", onConnection); + const close = () => { + if (closed) return; + closed = true; + server.off("connection", onConnection); + for (const socket of sockets) socket.destroy(); + }; + return Object.freeze({ + close, + async closeAndDrain() { + close(); + await Promise.allSettled([...closeWaiters]); + }, + }); +} + +export interface VerifiedUnixSocketOwnedPathListenOptions { + readonly path: string; + readonly parentPolicy: UnixPathPolicy; + readonly socketPolicy: UnixPathPolicy; + /** Held from stale-socket proof through bind; closed by server drain. */ + readonly pathLock: UnixSocketPathLock; + readonly inheritedFd?: never; +} + +export interface VerifiedUnixSocketInheritedListenOptions { + /** An already-bound, already-listening Unix socket descriptor, normally from systemd. */ + readonly inheritedFd: number; + readonly path?: never; + readonly parentPolicy?: never; + readonly socketPolicy?: never; + readonly pathLock?: never; +} + +export type VerifiedUnixSocketListenOptions = + | VerifiedUnixSocketOwnedPathListenOptions + | VerifiedUnixSocketInheritedListenOptions; + +export interface VerifiedUnixSocketServerOptions { + /** Fail closed if composition accidentally supplies the legacy owned-path mode. */ + readonly listenerMode?: "owned-path-or-inherited" | "inherited-fd-only"; +} + +export interface VerifiedUnixSocketServer { + listen(options: VerifiedUnixSocketListenOptions): Promise; + closeAndDrain(timeoutMs?: number): Promise; +} + +function assertInheritedPeerPolicy(policy: PeerCredentialPolicy): void { + if (!policy || !Number.isSafeInteger(policy.uid) || policy.uid <= 0 || policy.uid > 0xffff_ffff || policy.allowRoot === true) + throw new Error("Inherited Unix listeners require an exact expected non-root peer UID"); +} + +/** Proves the inherited descriptor is an already-listening AF_UNIX socket. */ +export async function assertInheritedUnixListenerDescriptor(fd: number): Promise { + if (process.platform !== "linux") throw new Error("Inherited Unix listeners are supported only on linux"); + const ffi = await import("bun:ffi"); + const library = ffi.dlopen("libc.so.6", { + getsockname: { args: ["int", "ptr", "ptr"], returns: "int" }, + getsockopt: { args: ["int", "int", "int", "ptr", "ptr"], returns: "int" }, + } as const); + try { + const address = new Uint8Array(128); + const addressLength = new Uint32Array([address.byteLength]); + if (library.symbols.getsockname(fd, ffi.ptr(address), ffi.ptr(addressLength)) !== 0 || addressLength[0] < 2) + throw new Error("Inherited listener descriptor is not a socket"); + const littleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1; + if (new DataView(address.buffer).getUint16(0, littleEndian) !== 1) + throw new Error("Inherited listener descriptor is not Unix-domain"); + const accepting = new Int32Array(1); + const acceptingLength = new Uint32Array([accepting.byteLength]); + // Linux SOL_SOCKET and SO_ACCEPTCONN. + if (library.symbols.getsockopt(fd, 1, 30, ffi.ptr(accepting), ffi.ptr(acceptingLength)) !== 0 || + acceptingLength[0] !== accepting.byteLength || accepting[0] !== 1) + throw new Error("Inherited Unix descriptor is not listening"); + } finally { + library.close(); + } +} + +/** Owns the raw server so unverified sockets cannot reach another listener. */ +export function createVerifiedUnixSocketServer( + verifier: LinuxPeerCredentialVerifier, + policy: PeerCredentialPolicy, + accept: (accepted: VerifiedAcceptedSocket) => void | Promise, + onServerError: (error: Error) => void = () => {}, + options: VerifiedUnixSocketServerOptions = {}, +): VerifiedUnixSocketServer { + const listenerMode = options.listenerMode ?? "owned-path-or-inherited"; + if (listenerMode !== "owned-path-or-inherited" && listenerMode !== "inherited-fd-only") + throw new Error("Malformed verified Unix socket listener mode"); + const server = createServer(); + const gate = installVerifiedUnixSocketGate(server, verifier, policy, accept); + let state: "idle" | "starting" | "listening" | "failed" | "closed" = "idle"; + let terminalError: Error | undefined; + let pathLock: UnixSocketPathLock | undefined; + server.on("error", (error) => { + terminalError ??= error; + state = "failed"; + gate.close(); + if (server.listening) server.close(); + try { onServerError(error); } catch {} + }); + return Object.freeze({ + async listen(options: VerifiedUnixSocketListenOptions) { + if (state !== "idle") throw new Error(`Verified Unix socket server cannot listen while ${state}`); + state = "starting"; + try { + if ("inheritedFd" in options) { + assertInheritedPeerPolicy(policy); + const inheritedFd = options.inheritedFd; + if (typeof inheritedFd !== "number" || !Number.isSafeInteger(inheritedFd) || inheritedFd < 3 || inheritedFd > 0x7fff_ffff) + throw new Error("Malformed inherited Unix listener descriptor"); + await assertInheritedUnixListenerDescriptor(inheritedFd); + await new Promise((resolveListen, rejectListen) => { + const onError = (error: Error) => rejectListen(error); + server.once("error", onError); + server.listen({ fd: inheritedFd }, () => { + server.off("error", onError); + if (terminalError || state !== "starting") { + rejectListen(terminalError ?? new Error("Verified Unix socket server failed while starting")); + return; + } + state = "listening"; + resolveListen(); + }); + }); + } else { + if (listenerMode === "inherited-fd-only") + throw new Error("Verified Unix socket server requires an inherited listener descriptor"); + pathLock = options.pathLock; + assertLivePathLock(pathLock, options.path); + validPathPolicy(options.parentPolicy); + validPathPolicy(options.socketPolicy); + requireCurrentProcessOwner(options.parentPolicy); + requireCurrentProcessOwner(options.socketPolicy); + await validateUnixSocketParent(dirname(options.path), options.parentPolicy); + await new Promise((resolveListen, rejectListen) => { + const onError = (error: Error) => rejectListen(error); + server.once("error", onError); + server.listen(options.path, async () => { + server.off("error", onError); + try { + await chmod(options.path, options.socketPolicy.mode); + await validateUnixSocketPath(options.path, options.socketPolicy); + if (terminalError || state !== "starting") + throw terminalError ?? new Error("Verified Unix socket server failed while starting"); + state = "listening"; + resolveListen(); + } catch (error) { + rejectListen(error); + } + }); + }); + } + } catch (error) { + terminalError ??= error instanceof Error ? error : new Error("Unix socket startup failed"); + state = "failed"; + gate.close(); + if (server.listening) server.close(); + await pathLock?.close(); + throw terminalError; + } + }, + async closeAndDrain(timeoutMs = 5_000) { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) + throw new Error("Invalid verified socket drain timeout"); + state = "closed"; + const stopped = server.listening + ? new Promise((resolveClose) => server.close(() => resolveClose())) + : Promise.resolve(); + const settled = Promise.all([gate.closeAndDrain(), stopped]); + void settled.then(() => pathLock?.close()).catch(() => {}); + let timer: ReturnType | undefined; + try { + await Promise.race([ + settled, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Verified Unix socket drain timed out")), timeoutMs); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + }, + }); +} + +export interface PeerCredentialDoctorReport { + readonly ok: boolean; + readonly platform: string; + readonly runtime: string; + readonly expectedUid: number; + readonly reason?: string; +} + +/** No work occurs until called; the report intentionally omits pid/gid/uid evidence. */ +export async function doctorLinuxPeerCredentials( + socket: Socket, + expectedUid: number, + createVerifier: () => Promise, +): Promise { + const runtime = typeof Bun === "undefined" ? "unsupported" : `bun-${Bun.version}`; + const base = { platform: process.platform, runtime, expectedUid }; + let verifier: LinuxPeerCredentialVerifier | undefined; + try { + verifier = await createVerifier(); + verifier.verify(socket, { uid: expectedUid }); + return Object.freeze({ ok: true, ...base }); + } catch (error) { + return Object.freeze({ ok: false, ...base, reason: error instanceof Error ? error.message : "verification failed" }); + } finally { + verifier?.close(); + } +} diff --git a/packages/core/opensession-server/src/server/session-kernel/actor-client.ts b/packages/core/opensession-server/src/server/session-kernel/actor-client.ts index 87d5db1a17..2a357fdc83 100644 --- a/packages/core/opensession-server/src/server/session-kernel/actor-client.ts +++ b/packages/core/opensession-server/src/server/session-kernel/actor-client.ts @@ -1,4 +1,10 @@ import type { SessionActorReducerCommand } from "./lifecycle-protocol"; +import type { + AgentHostPlanRegistration, + AgentHostPlanRegistrationResult, + AgentHostSupervisionRequest, + AgentHostSupervisionResult, +} from "./agent-host-supervision-protocol"; import { type CreationEventDecision, type CreationEventDecisionResult, @@ -21,6 +27,14 @@ import type { DeliveryMutationReply, } from "./delivery-protocol"; import type { AskActorRequest, AskActorResult } from "./ask-protocol"; +import type { + AgentOperationCancel, + AgentOperationCancellationIntent, + AgentOperationCancellationResult, + AgentOperationIdentity, + AgentOperationRequest, + AgentOperationResult, +} from "./agent-operation-protocol"; import type { TurnActorRequest, TurnActorResult } from "./turn-protocol"; import type { TimerActorRequest, TimerActorResult } from "./timer-protocol"; import type { GatewayCommandRequest, GatewayCommandResult } from "./gateway-command-protocol"; @@ -327,6 +341,43 @@ export class SessionKernelActorClient { ); } + decideAgentOperationAsync( + request: T, + ): Promise< + T extends AgentOperationCancel + ? AgentOperationCancellationResult + : AgentOperationResult + > { + return this.callAsync< + T extends AgentOperationCancel + ? AgentOperationCancellationResult + : AgentOperationResult + >( + { + t: "reduce", + command: { + kind: "agent_operation", + commandId: request.identity.operationId, + request, + }, + }, + `Agent operation ${request.op}`, + ); + } + + agentOperationCancellationIntentAsync( + identity: AgentOperationIdentity, + ): Promise { + return this.callAsync( + { + t: "store", + method: "agentOperationCancellationIntent", + args: [identity], + }, + "Agent operation cancellation intent query", + ); + } + async decideAskAsync( request: T, ): Promise> { @@ -457,6 +508,31 @@ export class SessionKernelActorClient { return (response as DeliveryMutationReply>).result; } + async decideAgentHostSupervisionAsync( + request: T, + ): Promise { + return this.callAsync< + AgentHostPlanRegistrationResult | AgentHostSupervisionResult + >( + { + t: "reduce", + command: { + kind: "agent_host_supervision", + commandId: + request.op === "register_plan" + ? request.registrationId + : request.claimId, + request, + }, + }, + "Agent Host supervision claim", + ) as Promise; + } + async decideCreationEventAsync( decision: CreationEventDecision, ): Promise { diff --git a/packages/core/opensession-server/src/server/session-kernel/actor-protocol.ts b/packages/core/opensession-server/src/server/session-kernel/actor-protocol.ts index d4cbec2963..44a25072bc 100644 --- a/packages/core/opensession-server/src/server/session-kernel/actor-protocol.ts +++ b/packages/core/opensession-server/src/server/session-kernel/actor-protocol.ts @@ -94,6 +94,8 @@ export type KernelActorRunEventResult = RunEventDecisionResult; export function isCriticalSettlementCommand( command: SessionActorReducerCommand, ): boolean { + if (command.kind === "agent_operation") + return command.request.op === "settle" || command.request.op === "indeterminate"; if (command.kind === "gateway") return command.request.op === "complete" || command.request.op === "fail"; if (command.kind === "core") diff --git a/packages/core/opensession-server/src/server/session-kernel/actor-routing.ts b/packages/core/opensession-server/src/server/session-kernel/actor-routing.ts index fc3a2e29f2..6ab6b46f4a 100644 --- a/packages/core/opensession-server/src/server/session-kernel/actor-routing.ts +++ b/packages/core/opensession-server/src/server/session-kernel/actor-routing.ts @@ -26,6 +26,19 @@ export function sessionActorReducerRoute( command: SessionActorReducerCommand, ): SessionActorRoute { switch (command.kind) { + case "agent_operation": + // Queries fail closed by durably quarantining contradictory receipts. + return { + scope: "session", + sessionId: command.request.identity.sessionId, + mutation: true, + }; + case "agent_host_supervision": + return { + scope: "session", + sessionId: command.request.sessionId, + mutation: true, + }; case "creation_event": return { scope: "session", diff --git a/packages/core/opensession-server/src/server/session-kernel/actor-worker.ts b/packages/core/opensession-server/src/server/session-kernel/actor-worker.ts index 1907fe7935..a6fa45df22 100644 --- a/packages/core/opensession-server/src/server/session-kernel/actor-worker.ts +++ b/packages/core/opensession-server/src/server/session-kernel/actor-worker.ts @@ -94,7 +94,9 @@ export function startSessionKernelActorWorker(): void { command.kind === "transcript" ? false : !isReadReducer(command), reducerMutatesSparseProjection(command), ); - if ( + if (command.kind === "agent_operation") + result = store.decideAgentOperation(command.request); + else if ( command.kind === "transcript" && !isReadReducer(command) && command.request.op !== "delete" && @@ -102,6 +104,10 @@ export function startSessionKernelActorWorker(): void { ) throw new Error(`Session ${command.request.sessionId} is tombstoned`); else if (command.kind === "transcript") result = host.transcript(command.request); + else if (command.kind === "agent_host_supervision") + result = command.request.op === "register_plan" + ? store.registerAgentHostPlan(command.request) + : store.claimAgentHostSupervision(command.request); else if (command.kind === "creation_event") result = store.applyCreationEvent(command.decision); else if (command.kind === "run_event") diff --git a/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-protocol.ts b/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-protocol.ts new file mode 100644 index 0000000000..0fdd193e3b --- /dev/null +++ b/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-protocol.ts @@ -0,0 +1,200 @@ +import { + AGENT_HOST_SUPERVISION_AUDIENCE, + AGENT_HOST_SUPERVISION_PURPOSE, + AGENT_HOST_SUPERVISION_VERSION, + decodeAgentHostSupervisionAuthorityV2, + type AgentHostSupervisionAuthorityV2, +} from "@tellahq/opensession-protocol/agent-host"; +import type { SignedAgentHostSupervisionEnvelopeV1 } from "@tellahq/opensession-protocol/agent-host-supervision"; +import { decodeExecutorId } from "@tellahq/opensession-protocol/executor"; + +export type AgentHostPlanRegistration = { + op: "register_plan"; + registrationId: string; + sessionId: string; + runId: string; + turnId: string; + generation: number; + planHash: string; +}; + +/** Untrusted V3 claim intent. Issuer metadata is intentionally absent. */ +export type AgentHostSupervisionClaim = { + op: "claim"; + claimId: string; + sessionId: string; + runId: string; + turnId: string; + generation: number; + planHash: string; + hostId: string; + hostGeneration: number; + hostIncarnation: string; + hostChallenge: string; +}; + +export type AgentHostSupervisionRequest = + AgentHostPlanRegistration | AgentHostSupervisionClaim; +export type AgentHostPlanRegistrationResult = + | { accepted: true; replayed: boolean } + | { + accepted: false; + reason: "stale_run" | "terminal_run" | "invalid_plan" | "plan_mismatch"; + }; +export type AgentHostSupervisionReceipt = { + format: "signed_v1"; + authority: AgentHostSupervisionAuthorityV2; + /** Preserved v26-compatible standard-base64 representation. */ + authorityBytes: string; + authorityHash: string; + keyId: string; + envelope: SignedAgentHostSupervisionEnvelopeV1; +}; +export type AgentHostSupervisionResult = + | { accepted: true; replayed: boolean; receipt: AgentHostSupervisionReceipt } + | { + accepted: false; + reason: + | "stale_run" + | "terminal_run" + | "invalid_claim" + | "claim_mismatch" + | "challenge_reused" + | "nonce_reused" + | "stale_host" + | "plan_unregistered" + | "plan_mismatch" + | "receipt_capacity" + | "issuer_unavailable"; + }; + +export type AgentHostSupervisionIssuerContext = { + readonly kernelServiceEpoch: string; + readonly keyId: string; + readonly leaseMs: number; + readonly now: () => number; + readonly nonce: () => string; + readonly sign: ( + canonicalAuthorityBytes: Uint8Array, + nowMs: number, + ) => SignedAgentHostSupervisionEnvelopeV1; +}; + +const PLAN_KEYS = [ + "op", + "registrationId", + "sessionId", + "runId", + "turnId", + "generation", + "planHash", +] as const; +const PLAN_HASH_RE = /^sha256:[a-f0-9]{64}$/; +const HOST_INCARNATION_RE = /^[A-Za-z0-9._:-]{8,256}$/; +const SUPERVISION_TOKEN_RE = /^[A-Za-z0-9_-]{16,256}$/; + +export function decodeAgentHostPlanRegistration( + value: unknown, +): AgentHostPlanRegistration | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) + return undefined; + const plan = value as Record; + if ( + Object.keys(plan).length !== PLAN_KEYS.length || + Object.keys(plan).some((key) => !PLAN_KEYS.includes(key as never)) || + plan.op !== "register_plan" || + !decodeExecutorId(plan.registrationId) || + !decodeExecutorId(plan.sessionId) || + !decodeExecutorId(plan.runId) || + !decodeExecutorId(plan.turnId) || + !Number.isSafeInteger(plan.generation) || + (plan.generation as number) < 0 || + typeof plan.planHash !== "string" || + !PLAN_HASH_RE.test(plan.planHash) + ) + return undefined; + return plan as AgentHostPlanRegistration; +} + +const CLAIM_KEYS = [ + "op", + "claimId", + "sessionId", + "runId", + "turnId", + "generation", + "planHash", + "hostId", + "hostGeneration", + "hostIncarnation", + "hostChallenge", +] as const; + +/** Exact V3 hard cut. Any former gateway-controlled issuer field is rejected. */ +export function decodeAgentHostSupervisionClaim( + value: unknown, +): AgentHostSupervisionClaim | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) + return undefined; + const claim = value as Record; + if ( + Object.keys(claim).length !== CLAIM_KEYS.length || + Object.keys(claim).some((key) => !CLAIM_KEYS.includes(key as never)) || + claim.op !== "claim" || + !decodeExecutorId(claim.claimId) || + !decodeExecutorId(claim.sessionId) || + !decodeExecutorId(claim.runId) || + !decodeExecutorId(claim.turnId) || + !Number.isSafeInteger(claim.generation) || + (claim.generation as number) < 0 || + typeof claim.planHash !== "string" || + !PLAN_HASH_RE.test(claim.planHash) || + !decodeExecutorId(claim.hostId) || + !Number.isSafeInteger(claim.hostGeneration) || + (claim.hostGeneration as number) < 1 || + typeof claim.hostIncarnation !== "string" || + !HOST_INCARNATION_RE.test(claim.hostIncarnation) || + typeof claim.hostChallenge !== "string" || + !SUPERVISION_TOKEN_RE.test(claim.hostChallenge) + ) + return undefined; + return claim as AgentHostSupervisionClaim; +} + +export function authorityFromAgentHostSupervisionClaim( + claim: AgentHostSupervisionClaim, + issuer: Readonly<{ + supervisorEpoch: number; + kernelServiceEpoch: string; + issuedAtMs: number; + expiresAtMs: number; + nonce: string; + keyId: string; + }>, +): AgentHostSupervisionAuthorityV2 | undefined { + return decodeAgentHostSupervisionAuthorityV2( + { + version: AGENT_HOST_SUPERVISION_VERSION, + fence: { + sessionId: claim.sessionId, + runId: claim.runId, + turnId: claim.turnId, + generation: claim.generation, + }, + planHash: claim.planHash, + hostId: claim.hostId, + hostGeneration: claim.hostGeneration, + hostIncarnation: claim.hostIncarnation, + supervisorEpoch: issuer.supervisorEpoch, + kernelServiceEpoch: issuer.kernelServiceEpoch, + hostChallenge: claim.hostChallenge, + audience: AGENT_HOST_SUPERVISION_AUDIENCE, + purpose: AGENT_HOST_SUPERVISION_PURPOSE, + issuedAtMs: issuer.issuedAtMs, + expiresAtMs: issuer.expiresAtMs, + nonce: issuer.nonce, + keyId: issuer.keyId, + }, + issuer.issuedAtMs, + ); +} diff --git a/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-signer.ts b/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-signer.ts new file mode 100644 index 0000000000..60823442c5 --- /dev/null +++ b/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-signer.ts @@ -0,0 +1,174 @@ +import { + createPrivateKey, + createPublicKey, + sign, + timingSafeEqual, + type KeyObject, +} from "node:crypto"; +import { MAX_AGENT_HOST_SUPERVISION_CLOCK_SKEW_MS } from "@tellahq/opensession-protocol/agent-host"; +import { + AGENT_HOST_SUPERVISION_ENVELOPE_VERSION, + AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN, + agentHostSupervisionSigningBytesV1, + decodeCanonicalAgentHostSupervisionAuthorityBytesV2, + type SignedAgentHostSupervisionEnvelopeV1, +} from "@tellahq/opensession-protocol/agent-host-supervision"; + +const ED25519_PKCS8_BYTES = 48; +const ED25519_SPKI_BYTES = 44; +const KEY_ID_RE = /^[A-Za-z0-9_-]{16,128}$/; +const PKCS8_PREFIX = Buffer.from([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, + 0x22, 0x04, 0x20, +]); +const SPKI_PREFIX = Buffer.from([ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, +]); + +/** Trusted credential material. Mutable DER inputs are wiped after import. + * Node KeyObject internals cannot be explicitly zeroized, and JavaScript cannot + * wipe prior string copies. Callers should load credentials directly into + * mutable buffers and keep the signer scoped to the kernel process. */ +export interface AgentHostSupervisionPrivateSigningKeyV2 { + readonly keyId: string; + readonly privateKeyPkcs8: Uint8Array; + readonly publicKeySpki: Uint8Array; + readonly signingNotBeforeMs: number; + readonly signingNotAfterMs: number; + readonly verifyUntilMs: number; + readonly status: "active"; +} + +export interface AgentHostSupervisionSynchronousSigner { + readonly keyId: string; + readonly publicKeySpki: string; + readonly signingNotBeforeMs: number; + readonly signingNotAfterMs: number; + readonly verifyUntilMs: number; + sign( + canonicalAuthorityBytes: Uint8Array, + nowMs: number, + ): SignedAgentHostSupervisionEnvelopeV1; +} + +function exactConfig(value: AgentHostSupervisionPrivateSigningKeyV2): boolean { + return ( + !!value && + typeof value === "object" && + Object.keys(value).length === 7 && + Object.keys(value).every((key) => + [ + "keyId", + "privateKeyPkcs8", + "publicKeySpki", + "signingNotBeforeMs", + "signingNotAfterMs", + "verifyUntilMs", + "status", + ].includes(key), + ) && + KEY_ID_RE.test(value.keyId) && + value.status === "active" && + value.privateKeyPkcs8 instanceof Uint8Array && + value.privateKeyPkcs8.byteLength === ED25519_PKCS8_BYTES && + value.publicKeySpki instanceof Uint8Array && + value.publicKeySpki.byteLength === ED25519_SPKI_BYTES && + Number.isSafeInteger(value.signingNotBeforeMs) && + value.signingNotBeforeMs >= 0 && + Number.isSafeInteger(value.signingNotAfterMs) && + value.signingNotAfterMs > value.signingNotBeforeMs && + Number.isSafeInteger(value.verifyUntilMs) && + value.verifyUntilMs >= value.signingNotAfterMs + ); +} + +function exportSpki(key: KeyObject): Buffer { + return key.export({ type: "spki", format: "der" }) as Buffer; +} + +/** Import-inert Node-only signer. Key import and public-key derivation happen + * exactly once; each mailbox call performs only bounded CPU work. */ +export function createAgentHostSupervisionSigner( + config: AgentHostSupervisionPrivateSigningKeyV2, +): AgentHostSupervisionSynchronousSigner { + if (!exactConfig(config)) + throw new Error("Invalid Agent Host signing key metadata"); + const pkcs8 = Buffer.from(config.privateKeyPkcs8); + const expectedSpki = Buffer.from(config.publicKeySpki); + // Take ownership semantics seriously: wipe caller-provided mutable DER too. + config.privateKeyPkcs8.fill(0); + try { + if (!pkcs8.subarray(0, PKCS8_PREFIX.length).equals(PKCS8_PREFIX)) + throw new Error("Invalid exact Ed25519 PKCS8 key"); + if (!expectedSpki.subarray(0, SPKI_PREFIX.length).equals(SPKI_PREFIX)) + throw new Error("Invalid exact Ed25519 SPKI key"); + const privateKey = createPrivateKey({ + key: pkcs8, + format: "der", + type: "pkcs8", + }); + if (privateKey.asymmetricKeyType !== "ed25519") + throw new Error("Signing key is not Ed25519"); + const derived = exportSpki(createPublicKey(privateKey)); + try { + if ( + derived.byteLength !== expectedSpki.byteLength || + !timingSafeEqual(derived, expectedSpki) + ) + throw new Error("Agent Host public key does not match private key"); + } finally { + derived.fill(0); + } + const publicKeySpki = expectedSpki.toString("base64url"); + const { keyId, signingNotBeforeMs, signingNotAfterMs, verifyUntilMs } = + config; + return Object.freeze({ + keyId, + publicKeySpki, + signingNotBeforeMs, + signingNotAfterMs, + verifyUntilMs, + sign(canonicalAuthorityBytes: Uint8Array, nowMs: number) { + if ( + !Number.isSafeInteger(nowMs) || + nowMs < signingNotBeforeMs || + nowMs >= signingNotAfterMs + ) + throw new Error( + "Agent Host signing key is outside its signing window", + ); + const authorityBytes = Uint8Array.from(canonicalAuthorityBytes); + const authority = decodeCanonicalAgentHostSupervisionAuthorityBytesV2( + authorityBytes, + nowMs, + ); + if ( + !authority || + authority.keyId !== keyId || + authority.issuedAtMs < signingNotBeforeMs || + authority.issuedAtMs >= signingNotAfterMs || + authority.expiresAtMs > + verifyUntilMs - MAX_AGENT_HOST_SUPERVISION_CLOCK_SKEW_MS + ) + throw new Error( + "Agent Host authority is invalid for this signing key", + ); + const signingBytes = agentHostSupervisionSigningBytesV1(authorityBytes); + const signature = sign(null, signingBytes, privateKey); + if (signature.byteLength !== 64) + throw new Error("Invalid Ed25519 signature length"); + return Object.freeze({ + version: AGENT_HOST_SUPERVISION_ENVELOPE_VERSION, + algorithm: AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + domain: AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN, + authorityBytes: Buffer.from(authorityBytes).toString("base64url"), + signature: signature.toString("base64url"), + }); + }, + }); + } finally { + pkcs8.fill(0); + expectedSpki.fill(0); + } +} diff --git a/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-signing.test.ts b/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-signing.test.ts new file mode 100644 index 0000000000..d90967a8df --- /dev/null +++ b/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision-signing.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { Worker } from "node:worker_threads"; +import { + AGENT_HOST_SUPERVISION_AUDIENCE, + AGENT_HOST_SUPERVISION_PURPOSE, + serializeAgentHostSupervisionAuthorityV2, +} from "@tellahq/opensession-protocol/agent-host"; +import { + decodeAgentHostSupervisionPublicKeyringV2, + verifySignedAgentHostSupervisionEnvelopeV2, +} from "@tellahq/opensession-protocol/agent-host-supervision"; +import { createAgentHostSupervisionSigner } from "./agent-host-supervision-signer"; + +const now = 1_000_000; +function fixture() { + const pair = generateKeyPairSync("ed25519"); + const privateKeyPkcs8 = Uint8Array.from( + pair.privateKey.export({ type: "pkcs8", format: "der" }), + ); + const publicKeySpki = Uint8Array.from( + pair.publicKey.export({ type: "spki", format: "der" }), + ); + const signer = createAgentHostSupervisionSigner({ + keyId: "agent-host-key-0001", + privateKeyPkcs8, + publicKeySpki, + signingNotBeforeMs: now - 1, + signingNotAfterMs: now + 100_000, + verifyUntilMs: now + 200_000, + status: "active", + }); + const authority = { + version: 2 as const, + fence: { + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, + }, + planHash: `sha256:${"a".repeat(64)}`, + hostId: "host-0001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + supervisorEpoch: 1, + kernelServiceEpoch: "service-epoch-0001", + hostChallenge: "challenge-00000001", + audience: AGENT_HOST_SUPERVISION_AUDIENCE, + purpose: AGENT_HOST_SUPERVISION_PURPOSE, + issuedAtMs: now, + expiresAtMs: now + 60_000, + nonce: "nonce-000000000001", + keyId: signer.keyId, + }; + return { + signer, + authority, + publicKeySpki: signer.publicKeySpki, + privateKeyPkcs8, + }; +} + +describe("synchronous Agent Host signer", () => { + test("is deterministic, wipes input DER, and verifies exact V3 bindings", async () => { + const { signer, authority, publicKeySpki, privateKeyPkcs8 } = fixture(); + expect(privateKeyPkcs8.every((byte) => byte === 0)).toBe(true); + const bytes = serializeAgentHostSupervisionAuthorityV2(authority); + const first = signer.sign(bytes, now); + expect(signer.sign(bytes, now)).toEqual(first); + const keyring = { + version: 2 as const, + algorithm: "Ed25519" as const, + domain: "opensession.agent-host.supervision.v2" as const, + keys: [ + { + keyId: signer.keyId, + status: "active" as const, + publicKeySpki, + signingNotBeforeMs: now - 1, + signingNotAfterMs: now + 100_000, + verifyUntilMs: now + 200_000, + }, + ], + }; + expect(decodeAgentHostSupervisionPublicKeyringV2(keyring)).toBeDefined(); + const expected = { + fence: authority.fence, + planHash: authority.planHash, + hostId: authority.hostId, + hostGeneration: authority.hostGeneration, + hostIncarnation: authority.hostIncarnation, + supervisorEpoch: authority.supervisorEpoch, + kernelServiceEpoch: authority.kernelServiceEpoch, + hostChallenge: authority.hostChallenge, + nonce: authority.nonce, + audience: authority.audience, + purpose: authority.purpose, + keyId: authority.keyId, + issuedAtMs: authority.issuedAtMs, + expiresAtMs: authority.expiresAtMs, + }; + expect( + await verifySignedAgentHostSupervisionEnvelopeV2( + first, + keyring, + expected, + now, + ), + ).toEqual(authority); + expect( + await verifySignedAgentHostSupervisionEnvelopeV2( + first, + keyring, + { ...expected, expiresAtMs: now + 1 }, + now, + ), + ).toBeUndefined(); + const shortRetention = { + ...keyring, + keys: [ + { + ...keyring.keys[0]!, + signingNotAfterMs: now + 1, + verifyUntilMs: authority.expiresAtMs + 30_000 - 1, + }, + ], + }; + expect( + await verifySignedAgentHostSupervisionEnvelopeV2( + first, + shortRetention, + expected, + now, + ), + ).toBeUndefined(); + }); + + test("rejects algorithm/key/window/public mismatch and multiple active keys", () => { + const pair = generateKeyPairSync("ed25519"); + const other = generateKeyPairSync("ed25519"); + const config = (spki: Buffer) => ({ + keyId: "agent-host-key-0001", + privateKeyPkcs8: Uint8Array.from( + pair.privateKey.export({ type: "pkcs8", format: "der" }), + ), + publicKeySpki: Uint8Array.from(spki), + signingNotBeforeMs: now, + signingNotAfterMs: now + 10, + verifyUntilMs: now + 20, + status: "active" as const, + }); + expect(() => + createAgentHostSupervisionSigner( + config(other.publicKey.export({ type: "spki", format: "der" })), + ), + ).toThrow(); + const { signer, authority } = fixture(); + expect(() => + signer.sign( + serializeAgentHostSupervisionAuthorityV2(authority), + now + 100_000, + ), + ).toThrow(); + const key = { + keyId: signer.keyId, + status: "active", + publicKeySpki: signer.publicKeySpki, + signingNotBeforeMs: now, + signingNotAfterMs: now + 1, + verifyUntilMs: now + 2, + }; + expect( + decodeAgentHostSupervisionPublicKeyringV2({ + version: 2, + algorithm: "Ed25519", + domain: "opensession.agent-host.supervision.v2", + keys: [key, { ...key, keyId: "agent-host-key-0002" }], + }), + ).toBeUndefined(); + }); + + test("has bounded sync latency in a Bun Worker-compatible runtime", async () => { + const { signer, authority } = fixture(); + const bytes = serializeAgentHostSupervisionAuthorityV2(authority); + const start = performance.now(); + for (let i = 0; i < 100; i += 1) signer.sign(bytes, now); + expect(performance.now() - start).toBeLessThan(1_000); + expect(typeof Worker).toBe("function"); + }); + + test("module is import-inert", async () => { + const child = Bun.spawn( + [ + process.execPath, + "-e", + `await import(${JSON.stringify(new URL("./agent-host-supervision-signer.ts", import.meta.url).href)}); console.log("ok")`, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + expect(await child.exited).toBe(0); + expect(await new Response(child.stdout).text()).toBe("ok\n"); + }); +}); diff --git a/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision.test.ts b/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision.test.ts new file mode 100644 index 0000000000..5fbcaaadf1 --- /dev/null +++ b/packages/core/opensession-server/src/server/session-kernel/agent-host-supervision.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createAgentHostSupervisionSigner } from "./agent-host-supervision-signer"; +import { + SESSION_KERNEL_SCHEMA_VERSION, + SessionKernelStore, +} from "./store"; +import { + decodeAgentHostSupervisionClaim, + type AgentHostPlanRegistration, + type AgentHostSupervisionClaim, +} from "./agent-host-supervision-protocol"; + +const planHash = `sha256:${"a".repeat(64)}`; +const plan: AgentHostPlanRegistration = { + op: "register_plan", + registrationId: "registration-0001", + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, + planHash, +}; +const claim = ( + overrides: Partial = {}, +): AgentHostSupervisionClaim => ({ + op: "claim", + claimId: "claim-00000001", + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, + planHash, + hostId: "host-0001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + hostChallenge: "challenge-00000001", + ...overrides, +}); +function issuer(overrides: Record = {}) { + const pair = generateKeyPairSync("ed25519"); + const signer = createAgentHostSupervisionSigner({ + keyId: "agent-host-key-0001", + privateKeyPkcs8: Uint8Array.from( + pair.privateKey.export({ type: "pkcs8", format: "der" }), + ), + publicKeySpki: Uint8Array.from( + pair.publicKey.export({ type: "spki", format: "der" }), + ), + signingNotBeforeMs: 999_000, + signingNotAfterMs: 2_000_000, + verifyUntilMs: 3_000_000, + status: "active", + }); + let nonce = 0; + return { + kernelServiceEpoch: "service-epoch-0001", + keyId: signer.keyId, + leaseMs: 60_000, + now: () => 1_000_000, + nonce: () => `nonce-${String(++nonce).padStart(16, "0")}`, + sign: signer.sign, + ...overrides, + }; +} +function running(path = ":memory:", trusted = issuer()) { + const store = new SessionKernelStore(path, { + agentHostSupervisionIssuer: trusted, + }); + expect( + store.applyRunEvent({ + sessionId: "session-1", + event: "prompt", + runKey: "run-1", + }).accepted, + ).toBe(true); + expect(store.registerAgentHostPlan(plan).accepted).toBe(true); + return store; +} + +describe("schema 27 signed Agent Host receipts", () => { + test("hard-cuts V3 claims and rejects every gateway issuer field", () => { + expect(decodeAgentHostSupervisionClaim(claim())).toEqual(claim()); + for (const field of [ + "kernelServiceEpoch", + "issuedAtMs", + "expiresAtMs", + "nonce", + "keyId", + "audience", + "purpose", + "signature", + "authorityBytes", + ]) + expect( + decodeAgentHostSupervisionClaim({ ...claim(), [field]: "gateway" }), + ).toBeUndefined(); + }); + + test("fails closed without production issuer and signs atomically", () => { + const unavailable = new SessionKernelStore(":memory:"); + unavailable.applyRunEvent({ + sessionId: "session-1", + event: "prompt", + runKey: "run-1", + }); + unavailable.registerAgentHostPlan(plan); + expect(unavailable.claimAgentHostSupervision(claim())).toEqual({ + accepted: false, + reason: "issuer_unavailable", + }); + const store = running(); + const result = store.claimAgentHostSupervision(claim()); + expect(result.accepted && result.receipt.format).toBe("signed_v1"); + expect( + result.accepted && + Buffer.from( + result.receipt.envelope.authorityBytes, + "base64url", + ).toString("base64"), + ).toBe(result.accepted && result.receipt.authorityBytes); + }); + + test("sign failure rolls back row, active status, and high water", () => { + const store = running( + ":memory:", + issuer({ + sign: () => { + throw new Error("sign failed"); + }, + }), + ); + expect(() => store.claimAgentHostSupervision(claim())).toThrow( + "sign failed", + ); + const db = (store as unknown as { db: Database }).db; + expect( + ( + db + .query( + "SELECT count(*) AS n FROM session_kernel_agent_host_supervision", + ) + .get() as { n: number } + ).n, + ).toBe(0); + expect( + ( + db + .query( + "SELECT supervisor_high_water AS n FROM session_kernel_agent_host_plan", + ) + .get() as { n: number } + ).n, + ).toBe(0); + }); + + test("exact replay is byte-identical after reopen without signer or old key", () => { + const dir = mkdtempSync(join(tmpdir(), "sk27-replay-")); + const path = join(dir, "db.sqlite"); + let store = running(path); + const first = store.claimAgentHostSupervision(claim()); + store.close(); + store = new SessionKernelStore(path); + const replay = store.claimAgentHostSupervision(claim()); + expect(replay).toEqual( + first.accepted ? { ...first, replayed: true } : first, + ); + store.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + test("challenge and kernel nonce are unique and corrupt envelopes fail closed", () => { + const fixedNonce = issuer({ nonce: () => "nonce-fixed-00000001" }); + const store = running(":memory:", fixedNonce); + expect(store.claimAgentHostSupervision(claim()).accepted).toBe(true); + expect( + store.claimAgentHostSupervision( + claim({ + claimId: "claim-00000002", + hostChallenge: "challenge-00000002", + }), + ), + ).toEqual({ accepted: false, reason: "nonce_reused" }); + const db = (store as unknown as { db: Database }).db; + db.run( + "UPDATE session_kernel_agent_host_supervision SET signature='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'", + ); + expect(() => store.claimAgentHostSupervision(claim())).toThrow( + "Contradictory durable signed Agent Host receipt", + ); + }); + + test("migrates populated v26 receipts as legacy and rolls contradictions back", () => { + function makeV26(path: string, corrupt = false) { + const store = running(path); + expect(store.claimAgentHostSupervision(claim()).accepted).toBe(true); + store.close(); + const db = new Database(path); + db.exec(` + DROP TABLE session_kernel_agent_operation_cancellations; + DROP TABLE session_kernel_agent_operations; + DROP TABLE session_kernel_agent_operation_high_water; + DROP INDEX idx_skahs_active; + DROP INDEX idx_skahs_prune; + ALTER TABLE session_kernel_agent_host_supervision RENAME TO signed_source; + CREATE TABLE session_kernel_agent_host_supervision ( + session_id TEXT NOT NULL, supervisor_epoch INTEGER NOT NULL, + claim_id TEXT NOT NULL, request_hash TEXT NOT NULL, run_id TEXT NOT NULL, + run_generation INTEGER NOT NULL, host_id TEXT NOT NULL, + host_generation INTEGER NOT NULL, host_incarnation TEXT NOT NULL, + kernel_service_epoch TEXT NOT NULL, challenge TEXT NOT NULL, nonce TEXT NOT NULL, + status TEXT NOT NULL, authority TEXT NOT NULL, authority_bytes TEXT NOT NULL, + authority_hash TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL, + PRIMARY KEY(session_id, supervisor_epoch), UNIQUE(session_id, claim_id), + UNIQUE(session_id, challenge), UNIQUE(session_id, nonce)); + INSERT INTO session_kernel_agent_host_supervision + SELECT session_id,supervisor_epoch,claim_id,request_hash,run_id,run_generation, + host_id,host_generation,host_incarnation,kernel_service_epoch,challenge,nonce, + status,authority,authority_bytes,authority_hash,expires_at,created_at FROM signed_source; + DROP TABLE signed_source; + CREATE UNIQUE INDEX idx_skahs_active ON session_kernel_agent_host_supervision(session_id) WHERE status='active'; + PRAGMA user_version=26; + `); + if (corrupt) + db.run( + "UPDATE session_kernel_agent_host_supervision SET authority_bytes='broken'", + ); + db.close(); + } + const dir = mkdtempSync(join(tmpdir(), "sk27-migrate-")); + const validPath = join(dir, "valid.sqlite"); + makeV26(validPath); + const migrated = new SessionKernelStore(validPath); + expect(migrated.claimAgentHostSupervision(claim())).toEqual({ + accepted: false, + reason: "issuer_unavailable", + }); + migrated.close(); + const validDb = new Database(validPath, { readonly: true }); + expect( + ( + validDb + .query( + "SELECT receipt_format FROM session_kernel_agent_host_supervision", + ) + .get() as { receipt_format: string } + ).receipt_format, + ).toBe("legacy_unsigned_v2"); + expect( + (validDb.query("PRAGMA user_version").get() as { user_version: number }) + .user_version, + ).toBe(SESSION_KERNEL_SCHEMA_VERSION); + validDb.close(); + + const corruptPath = join(dir, "corrupt.sqlite"); + makeV26(corruptPath, true); + expect(() => new SessionKernelStore(corruptPath)).toThrow( + "Contradictory durable Agent Host authority", + ); + const corruptDb = new Database(corruptPath, { readonly: true }); + expect( + (corruptDb.query("PRAGMA user_version").get() as { user_version: number }) + .user_version, + ).toBe(26); + expect( + ( + corruptDb + .query("PRAGMA table_info(session_kernel_agent_host_supervision)") + .all() as Array<{ name: string }> + ).some((column) => column.name === "receipt_format"), + ).toBe(false); + corruptDb.close(); + + function expectV26Rollback( + name: string, + mutate: (db: Database) => void, + message?: string, + ) { + const path = join(dir, `${name}.sqlite`); + makeV26(path); + const db = new Database(path); + mutate(db); + db.close(); + expect(() => new SessionKernelStore(path)).toThrow(message); + const after = new Database(path, { readonly: true }); + expect( + (after.query("PRAGMA user_version").get() as { user_version: number }) + .user_version, + ).toBe(26); + expect( + ( + after + .query("PRAGMA table_info(session_kernel_agent_host_supervision)") + .all() as Array<{ name: string }> + ).some((column) => column.name === "receipt_format"), + ).toBe(false); + after.close(); + } + expectV26Rollback("rebuild-index-collision", (db) => + db.exec( + "CREATE INDEX idx_skahs_prune ON session_kernel_agent_host_plan(session_id)", + ), + ); + expectV26Rollback( + "null-plan-host", + (db) => db.run("UPDATE session_kernel_agent_host_plan SET host_id=NULL"), + "Agent Host plan high-water regression", + ); + expectV26Rollback( + "active-plan-mismatch", + (db) => + db.run("UPDATE session_kernel_agent_host_plan SET plan_hash=?", [ + `sha256:${"b".repeat(64)}`, + ]), + "Agent Host plan high-water regression", + ); + rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/packages/core/opensession-server/src/server/session-kernel/agent-operation-protocol.ts b/packages/core/opensession-server/src/server/session-kernel/agent-operation-protocol.ts new file mode 100644 index 0000000000..26256b6f6c --- /dev/null +++ b/packages/core/opensession-server/src/server/session-kernel/agent-operation-protocol.ts @@ -0,0 +1,584 @@ +import type { + AgentOperationKind, + AgentTranscriptReceiptRefV1, +} from "@tellahq/opensession-protocol/agent-operation"; + +export const AGENT_OPERATION_EVIDENCE_HORIZON_MS = 7 * 24 * 60 * 60 * 1_000; +export const SESSION_KERNEL_MAX_AGENT_OPERATIONS_PER_TURN = 256; +export const SESSION_KERNEL_MAX_AGENT_OPERATIONS_PER_SESSION = 4_096; +export const MAX_AGENT_OPERATION_PROTOCOL_BYTES = 64 * 1024; +export const MAX_AGENT_OPERATION_PROTOCOL_DEPTH = 12; +const ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511}$/; +const DIGEST = /^sha256:[a-f0-9]{64}$/; +const VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/; +const OUTCOME = /^[a-z][a-z0-9_]{0,63}$/; +const FORBIDDEN = + /^(?:accountId|apiKey|args|arguments|authorization|authToken|baseUrl|body|cookie|credentials?|env|environment|headers?|password|prompt|providerConfig|requestBody|responseBody|secret|token|accessToken|url|metadata)$/i; +const record = (value: unknown): value is Record => + !!value && + typeof value === "object" && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype; +const exact = (value: Record, keys: readonly string[]) => + Object.keys(value).length === keys.length && + Object.keys(value).every((key) => keys.includes(key)); +const id = (value: unknown): value is string => + typeof value === "string" && ID.test(value); +const digest = (value: unknown): value is `sha256:${string}` => + typeof value === "string" && DIGEST.test(value); +const integer = (value: unknown): value is number => + Number.isSafeInteger(value) && (value as number) >= 0; + +function safe(value: unknown): boolean { + const seen = new Set(); + let count = 0; + const visit = (item: unknown, depth: number): boolean => { + if (depth > MAX_AGENT_OPERATION_PROTOCOL_DEPTH || ++count > 2_048) + return false; + if (typeof item === "number") return Number.isFinite(item); + if (item === null || typeof item === "string" || typeof item === "boolean") + return true; + if (typeof item !== "object" || seen.has(item as object)) return false; + seen.add(item as object); + if (Array.isArray(item)) { + if (Object.getPrototypeOf(item) !== Array.prototype) return false; + const descriptors = Object.getOwnPropertyDescriptors(item); + const keys = Reflect.ownKeys(descriptors); + if ( + keys.some((key) => typeof key !== "string") || + keys.length !== item.length + 1 + ) + return false; + for (let index = 0; index < item.length; index++) { + const descriptor = descriptors[String(index)]; + if ( + !descriptor || + !("value" in descriptor) || + !descriptor.enumerable || + descriptor.value === undefined || + !visit(descriptor.value, depth + 1) + ) + return false; + } + return true; + } + if (!record(item)) return false; + const descriptors = Object.getOwnPropertyDescriptors(item); + return Reflect.ownKeys(descriptors).every((key) => { + if (typeof key !== "string") return false; + const descriptor = descriptors[key]; + return ( + !!descriptor && + "value" in descriptor && + descriptor.enumerable && + descriptor.value !== undefined && + !FORBIDDEN.test(key) && + visit(descriptor.value, depth + 1) + ); + }); + }; + try { + if (!visit(value, 0)) return false; + const snapshot = structuredClone(value); + return ( + Buffer.byteLength(JSON.stringify(snapshot)) <= + MAX_AGENT_OPERATION_PROTOCOL_BYTES + ); + } catch { + return false; + } +} + +export type AgentOperationAnchor = { + throughChangeSeq: number; + digest: `sha256:${string}`; + entryIds: readonly string[]; +}; +export type AgentOperationIdentity = { + sessionId: string; + runId: string; + turnId: string; + generation: number; + operationId: string; + kind: AgentOperationKind; + descriptorDigest: `sha256:${string}`; + payloadDigest: `sha256:${string}`; + adapterId: string; + adapterVersion: string; + authorityHash: `sha256:${string}`; + supervisorEpoch: number; + planHash: `sha256:${string}`; + hostId: string; + hostGeneration: number; + hostIncarnation: string; + transcriptAnchor: AgentOperationAnchor; + /** Required only for MCP operations and bound to the exact durable model tool-use entry. */ + toolUseEntryId?: string; +}; +export type AgentOperationAdmit = { + op: "admit"; + identity: AgentOperationIdentity; +}; +export type AgentOperationTerminal = { + op: "settle" | "indeterminate"; + identity: AgentOperationIdentity; + gatewayReceiptDigest: `sha256:${string}`; + outputDigest: `sha256:${string}`; + outcomeCode: string; + transcriptReceipts: readonly AgentTranscriptReceiptRefV1[]; + /** Required only for model terminals. Ordered, bounded, and unique. */ + pendingToolUseEntryIds?: readonly string[]; +}; +export type AgentOperationQuery = { + op: "query"; + identity: AgentOperationIdentity; +}; +export const AGENT_OPERATION_CANCELLATION_REASONS = [ + "user", + "turn_deadline", + "shutdown", + "reconnect_deadline", +] as const; +export type AgentOperationCancellationReason = + (typeof AGENT_OPERATION_CANCELLATION_REASONS)[number]; +export type AgentOperationCancel = { + op: "cancel"; + identity: AgentOperationIdentity; + cancelId: string; + reason: AgentOperationCancellationReason; +}; +export type AgentOperationRequest = + | AgentOperationAdmit + | AgentOperationTerminal + | AgentOperationQuery + | AgentOperationCancel; +export type AgentOperationReceipt = { + identity: AgentOperationIdentity; + sequence: number; + state: "admitted" | "settled" | "indeterminate"; + admittedAtMs: number; + terminalAtMs?: number; + gatewayReceiptDigest?: `sha256:${string}`; + outputDigest?: `sha256:${string}`; + outcomeCode?: string; + transcriptReceipts?: readonly AgentTranscriptReceiptRefV1[]; + pendingToolUseEntryIds?: readonly string[]; +}; +export type AgentOperationCancellationIntent = { + identity: AgentOperationIdentity; + cancelId: string; + reason: AgentOperationCancellationReason; + disposition: "requested" | "too_late"; + requestedAtMs: number; +}; +export type AgentOperationCancellationResult = + | { + accepted: true; + replayed: boolean; + intent: AgentOperationCancellationIntent; + } + | { + accepted: false; + reason: "invalid_request" | "not_found" | "operation_barrier"; + }; +export type AgentOperationResult = + | { accepted: true; replayed: boolean; receipt: AgentOperationReceipt } + | { + accepted: false; + reason: + | "invalid_request" + | "stale_run" + | "terminal_run" + | "plan_unregistered" + | "plan_mismatch" + | "authority_inactive" + | "authority_mismatch" + | "operation_barrier" + | "operation_order" + | "transcript_barrier" + | "indeterminate_turn" + | "receipt_capacity" + | "not_found"; + }; + +const IDENTITY_KEYS = [ + "sessionId", + "runId", + "turnId", + "generation", + "operationId", + "kind", + "descriptorDigest", + "payloadDigest", + "adapterId", + "adapterVersion", + "authorityHash", + "supervisorEpoch", + "planHash", + "hostId", + "hostGeneration", + "hostIncarnation", + "transcriptAnchor", +] as const; +function decodeAnchor(value: unknown): AgentOperationAnchor | undefined { + if ( + !record(value) || + !exact(value, ["throughChangeSeq", "digest", "entryIds"]) || + !integer(value.throughChangeSeq) || + !digest(value.digest) || + !Array.isArray(value.entryIds) || + value.entryIds.length > 512 || + !value.entryIds.every(id) || + new Set(value.entryIds).size !== value.entryIds.length + ) + return; + return { + throughChangeSeq: value.throughChangeSeq, + digest: value.digest, + entryIds: Object.freeze([...value.entryIds]), + }; +} +export function decodeAgentOperationIdentity( + value: unknown, +): AgentOperationIdentity | undefined { + if (!record(value)) return; + const identityKeys = + value.kind === "mcp" ? [...IDENTITY_KEYS, "toolUseEntryId"] : IDENTITY_KEYS; + if (!exact(value, identityKeys)) return; + const anchor = decodeAnchor(value.transcriptAnchor); + if ( + !id(value.sessionId) || + !id(value.runId) || + !id(value.turnId) || + !integer(value.generation) || + !id(value.operationId) || + (value.kind !== "model" && value.kind !== "mcp") || + !digest(value.descriptorDigest) || + !digest(value.payloadDigest) || + !id(value.adapterId) || + typeof value.adapterVersion !== "string" || + !VERSION.test(value.adapterVersion) || + !digest(value.authorityHash) || + !integer(value.supervisorEpoch) || + value.supervisorEpoch < 1 || + !digest(value.planHash) || + !id(value.hostId) || + !integer(value.hostGeneration) || + value.hostGeneration < 1 || + !id(value.hostIncarnation) || + !anchor || + (value.kind === "mcp" + ? !id(value.toolUseEntryId) + : value.toolUseEntryId !== undefined) + ) + return; + return Object.freeze({ + sessionId: value.sessionId, + runId: value.runId, + turnId: value.turnId, + generation: value.generation, + operationId: value.operationId, + kind: value.kind, + descriptorDigest: value.descriptorDigest, + payloadDigest: value.payloadDigest, + adapterId: value.adapterId, + adapterVersion: value.adapterVersion, + authorityHash: value.authorityHash, + supervisorEpoch: value.supervisorEpoch, + planHash: value.planHash, + hostId: value.hostId, + hostGeneration: value.hostGeneration, + hostIncarnation: value.hostIncarnation, + transcriptAnchor: Object.freeze(anchor), + ...(value.kind === "mcp" + ? { toolUseEntryId: value.toolUseEntryId as string } + : {}), + }); +} +function decodeTranscriptReceipt( + value: unknown, +): AgentTranscriptReceiptRefV1 | undefined { + if ( + !record(value) || + !exact(value, [ + "appendId", + "entryIds", + "firstSeq", + "lastSeq", + "throughChangeSeq", + "requestDigest", + ]) || + !id(value.appendId) || + !Array.isArray(value.entryIds) || + value.entryIds.length === 0 || + value.entryIds.length > 512 || + !value.entryIds.every(id) || + new Set(value.entryIds).size !== value.entryIds.length || + !integer(value.firstSeq) || + !integer(value.lastSeq) || + value.lastSeq < value.firstSeq || + value.entryIds.length !== value.lastSeq - value.firstSeq + 1 || + !integer(value.throughChangeSeq) || + !digest(value.requestDigest) + ) + return; + return Object.freeze({ + ...value, + entryIds: Object.freeze([...value.entryIds]), + }) as AgentTranscriptReceiptRefV1; +} +function decodePendingToolUseEntryIds( + refs: readonly AgentTranscriptReceiptRefV1[], + value: unknown, +): readonly string[] | undefined { + if ( + !Array.isArray(value) || + value.length > 64 || + !value.every(id) || + new Set(value).size !== value.length + ) + return; + const flattened = refs.flatMap((ref) => [...ref.entryIds]); + if (new Set(flattened).size !== flattened.length) return; + let priorIndex = -1; + for (const entryId of value) { + const index = flattened.indexOf(entryId); + if (index <= priorIndex) return; + priorIndex = index; + } + return Object.freeze([...value]); +} + +export function decodeAgentOperationRequest( + value: unknown, +): AgentOperationRequest | undefined { + if ( + !safe(value) || + !record(value) || + !["admit", "settle", "indeterminate", "query", "cancel"].includes( + String(value.op), + ) + ) + return; + const terminal = value.op === "settle" || value.op === "indeterminate"; + const cancellation = value.op === "cancel"; + if ( + !exact( + value, + terminal + ? [ + "op", + "identity", + "gatewayReceiptDigest", + "outputDigest", + "outcomeCode", + "transcriptReceipts", + ...(record(value.identity) && value.identity.kind === "model" + ? ["pendingToolUseEntryIds"] + : []), + ] + : cancellation + ? ["op", "identity", "cancelId", "reason"] + : ["op", "identity"], + ) + ) + return; + const identity = decodeAgentOperationIdentity(value.identity); + if (!identity) return; + if (cancellation) { + if ( + !id(value.cancelId) || + !AGENT_OPERATION_CANCELLATION_REASONS.includes( + value.reason as AgentOperationCancellationReason, + ) + ) + return; + return Object.freeze({ + op: "cancel", + identity, + cancelId: value.cancelId, + reason: value.reason, + }) as AgentOperationCancel; + } + if (!terminal) + return Object.freeze({ op: value.op, identity }) as + AgentOperationAdmit | AgentOperationQuery; + if ( + !digest(value.gatewayReceiptDigest) || + !digest(value.outputDigest) || + typeof value.outcomeCode !== "string" || + !OUTCOME.test(value.outcomeCode) || + !Array.isArray(value.transcriptReceipts) || + value.transcriptReceipts.length === 0 || + value.transcriptReceipts.length > 64 + ) + return; + const refs = value.transcriptReceipts.map(decodeTranscriptReceipt); + if ( + refs.some((entry) => !entry) || + refs.some( + (entry, index) => + index > 0 && + (entry!.firstSeq <= refs[index - 1]!.lastSeq || + entry!.throughChangeSeq <= refs[index - 1]!.throughChangeSeq), + ) + ) + return; + const pending = + identity.kind === "model" + ? decodePendingToolUseEntryIds( + refs as AgentTranscriptReceiptRefV1[], + value.pendingToolUseEntryIds, + ) + : undefined; + if ( + (identity.kind === "model" && !pending) || + (identity.kind === "mcp" && value.pendingToolUseEntryIds !== undefined) + ) + return; + return Object.freeze({ + op: value.op, + identity, + gatewayReceiptDigest: value.gatewayReceiptDigest, + outputDigest: value.outputDigest, + outcomeCode: value.outcomeCode, + transcriptReceipts: Object.freeze(refs as AgentTranscriptReceiptRefV1[]), + ...(identity.kind === "model" ? { pendingToolUseEntryIds: pending } : {}), + }) as AgentOperationTerminal; +} + +export function decodeAgentOperationReceipt( + value: unknown, +): AgentOperationReceipt | undefined { + if (!safe(value) || !record(value)) return; + const identity = decodeAgentOperationIdentity(value.identity); + if ( + !identity || + !integer(value.sequence) || + value.sequence < 1 || + !integer(value.admittedAtMs) || + !["admitted", "settled", "indeterminate"].includes(String(value.state)) + ) + return; + if (value.state === "admitted") { + if (!exact(value, ["identity", "sequence", "state", "admittedAtMs"])) + return; + return Object.freeze({ + identity, + sequence: value.sequence, + state: "admitted", + admittedAtMs: value.admittedAtMs, + }); + } + const terminalKeys = [ + "identity", + "sequence", + "state", + "admittedAtMs", + "terminalAtMs", + "gatewayReceiptDigest", + "outputDigest", + "outcomeCode", + "transcriptReceipts", + ...(identity.kind === "model" ? ["pendingToolUseEntryIds"] : []), + ]; + if ( + !exact(value, terminalKeys) || + !integer(value.terminalAtMs) || + value.terminalAtMs < value.admittedAtMs || + !digest(value.gatewayReceiptDigest) || + !digest(value.outputDigest) || + typeof value.outcomeCode !== "string" || + !OUTCOME.test(value.outcomeCode) || + !Array.isArray(value.transcriptReceipts) || + value.transcriptReceipts.length < 1 || + value.transcriptReceipts.length > 64 + ) + return; + const refs = value.transcriptReceipts.map(decodeTranscriptReceipt); + if ( + refs.some((entry) => !entry) || + refs.some( + (entry, index) => + index > 0 && + (entry!.firstSeq <= refs[index - 1]!.lastSeq || + entry!.throughChangeSeq <= refs[index - 1]!.throughChangeSeq), + ) + ) + return; + const pending = + identity.kind === "model" + ? decodePendingToolUseEntryIds( + refs as AgentTranscriptReceiptRefV1[], + value.pendingToolUseEntryIds, + ) + : undefined; + if ( + (identity.kind === "model" && !pending) || + (identity.kind === "mcp" && value.pendingToolUseEntryIds !== undefined) + ) + return; + return Object.freeze({ + identity, + sequence: value.sequence, + state: value.state, + admittedAtMs: value.admittedAtMs, + terminalAtMs: value.terminalAtMs, + gatewayReceiptDigest: value.gatewayReceiptDigest, + outputDigest: value.outputDigest, + outcomeCode: value.outcomeCode, + transcriptReceipts: Object.freeze(refs as AgentTranscriptReceiptRefV1[]), + ...(identity.kind === "model" ? { pendingToolUseEntryIds: pending } : {}), + }) as AgentOperationReceipt; +} + +export function decodeAgentOperationCancellationIntent( + value: unknown, +): AgentOperationCancellationIntent | undefined { + if ( + !safe(value) || + !record(value) || + !exact(value, [ + "identity", + "cancelId", + "reason", + "disposition", + "requestedAtMs", + ]) + ) + return; + const identity = decodeAgentOperationIdentity(value.identity); + if ( + !identity || + !id(value.cancelId) || + !AGENT_OPERATION_CANCELLATION_REASONS.includes( + value.reason as AgentOperationCancellationReason, + ) || + (value.disposition !== "requested" && value.disposition !== "too_late") || + !integer(value.requestedAtMs) + ) + return; + return Object.freeze({ + identity, + cancelId: value.cancelId, + reason: value.reason, + disposition: value.disposition, + requestedAtMs: value.requestedAtMs, + }) as AgentOperationCancellationIntent; +} + +export function canonicalAgentOperationIdentity( + value: AgentOperationIdentity, +): string { + return JSON.stringify(decodeAgentOperationIdentity(value)); +} +export function canonicalAgentOperationTerminal( + value: AgentOperationTerminal, +): string { + return JSON.stringify(decodeAgentOperationRequest(value)); +} +export function canonicalAgentOperationCancellation( + value: AgentOperationCancel, +): string { + return JSON.stringify(decodeAgentOperationRequest(value)); +} diff --git a/packages/core/opensession-server/src/server/session-kernel/agent-operation.test.ts b/packages/core/opensession-server/src/server/session-kernel/agent-operation.test.ts new file mode 100644 index 0000000000..8c6f0f839d --- /dev/null +++ b/packages/core/opensession-server/src/server/session-kernel/agent-operation.test.ts @@ -0,0 +1,656 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAgentHostSupervisionSigner } from "./agent-host-supervision-signer"; +import { + decodeAgentOperationRequest, + type AgentOperationIdentity, + type AgentOperationRequest, + type AgentOperationTerminal, +} from "./agent-operation-protocol"; +import { SessionKernelStore } from "./store"; + +const hash = (char: string) => + `sha256:${char.repeat(64)}` as `sha256:${string}`; +function fixture(path = ":memory:") { + const now = Date.now(); + const pair = generateKeyPairSync("ed25519"); + const signer = createAgentHostSupervisionSigner({ + keyId: "agent-host-key-0001", + privateKeyPkcs8: Uint8Array.from( + pair.privateKey.export({ type: "pkcs8", format: "der" }), + ), + publicKeySpki: Uint8Array.from( + pair.publicKey.export({ type: "spki", format: "der" }), + ), + signingNotBeforeMs: now - 1_000, + signingNotAfterMs: now + 1_000_000, + verifyUntilMs: now + 2_000_000, + status: "active", + }); + const store = new SessionKernelStore(path, { + agentHostSupervisionIssuer: { + kernelServiceEpoch: "kernel-epoch-0001", + keyId: signer.keyId, + leaseMs: 60_000, + now: () => now, + nonce: () => "nonce-0000000000000001", + sign: signer.sign, + }, + }); + store.applyRunEvent({ + sessionId: "session-1", + event: "prompt", + runKey: "run-1", + }); + store.registerAgentHostPlan({ + op: "register_plan", + registrationId: "registration-1", + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, + planHash: hash("a"), + }); + const authority = store.claimAgentHostSupervision({ + op: "claim", + claimId: "claim-00000001", + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, + planHash: hash("a"), + hostId: "host-0001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + hostChallenge: "challenge-00000001", + }); + if (!authority.accepted) throw new Error(authority.reason); + const identity: AgentOperationIdentity = { + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 1, + operationId: "operation-1", + kind: "model", + descriptorDigest: hash("b"), + payloadDigest: hash("c"), + adapterId: "adapter-1", + adapterVersion: "1.0", + authorityHash: authority.receipt.authorityHash as `sha256:${string}`, + supervisorEpoch: authority.receipt.authority.supervisorEpoch, + planHash: hash("a"), + hostId: "host-0001", + hostGeneration: 1, + hostIncarnation: "incarnation-0001", + transcriptAnchor: { + throughChangeSeq: 10, + digest: hash("d"), + entryIds: ["input-1"], + }, + }; + return { store, identity }; +} +function terminal( + identity: AgentOperationIdentity, + op: "settle" | "indeterminate" = "settle", + pendingToolUseEntryIds: readonly string[] = [], +): AgentOperationTerminal { + const entryIds = [`${identity.operationId}-output`]; + if (identity.kind === "model") entryIds.push(...pendingToolUseEntryIds); + return { + op, + identity, + gatewayReceiptDigest: hash("e"), + outputDigest: hash("f"), + outcomeCode: op === "settle" ? "ok" : "ambiguous_completion", + transcriptReceipts: [ + { + appendId: `append-${identity.operationId}`, + entryIds, + firstSeq: identity.transcriptAnchor.throughChangeSeq + 1, + lastSeq: identity.transcriptAnchor.throughChangeSeq + entryIds.length, + throughChangeSeq: identity.transcriptAnchor.throughChangeSeq + 1, + requestDigest: hash("1"), + }, + ], + ...(identity.kind === "model" ? { pendingToolUseEntryIds } : {}), + }; +} + +describe("schema 28 actor-owned Agent operations", () => { + test("strictly rejects unknown, secret, body, crossover, nonfinite and deep inputs", () => { + const { identity } = fixture(); + const valid = { op: "admit", identity } as const; + expect(decodeAgentOperationRequest(valid)).toBeDefined(); + for (const bad of [ + { ...valid, unknown: true }, + { ...valid, body: "secret" }, + { ...valid, identity: { ...identity, kind: "provider" } }, + { ...valid, identity: { ...identity, generation: NaN } }, + { ...valid, identity: { ...identity, prompt: "x" } }, + ]) + expect(decodeAgentOperationRequest(bad)).toBeUndefined(); + let nested: unknown = "x"; + for (let i = 0; i < 20; i++) nested = { x: nested }; + expect( + decodeAgentOperationRequest({ + ...valid, + identity: { + ...identity, + transcriptAnchor: { ...identity.transcriptAnchor, digest: nested }, + }, + }), + ).toBeUndefined(); + const accessor = { ...valid } as Record; + Object.defineProperty(accessor, "op", { + enumerable: true, + get: () => "admit", + }); + expect(decodeAgentOperationRequest(accessor)).toBeUndefined(); + expect( + decodeAgentOperationRequest( + new Proxy(valid, { ownKeys: () => ["op", "identity", "secret"] }), + ), + ).toBeUndefined(); + const contradictory = terminal(identity); + expect( + decodeAgentOperationRequest({ + ...contradictory, + transcriptReceipts: contradictory.transcriptReceipts.map((ref) => ({ + ...ref, + lastSeq: ref.lastSeq + 1, + })), + }), + ).toBeUndefined(); + }); + test("admits a declared two-tool batch in exact order and requires cumulative result anchors", () => { + const { store, identity } = fixture(); + const first = store.decideAgentOperation({ op: "admit", identity }); + expect(first.accepted && first.replayed).toBe(false); + expect(store.decideAgentOperation({ op: "admit", identity })).toEqual( + first.accepted ? { ...first, replayed: true } : first, + ); + const activeMcp = { + ...identity, + operationId: "operation-active", + kind: "mcp" as const, + toolUseEntryId: "tool-a", + }; + expect( + store.decideAgentOperation({ op: "admit", identity: activeMcp }), + ).toEqual({ + accepted: false, + reason: "operation_barrier", + }); + const reversed = terminal(identity, "settle", ["tool-a", "tool-b"]); + expect( + store.decideAgentOperation({ + ...reversed, + pendingToolUseEntryIds: ["tool-b", "tool-a"], + }), + ).toEqual({ accepted: false, reason: "invalid_request" }); + expect( + store.decideAgentOperation( + terminal(identity, "settle", ["tool-a", "tool-b"]), + ).accepted, + ).toBe(true); + + const modelEntries = ["operation-1-output", "tool-a", "tool-b"]; + const mcpBase = { + ...identity, + kind: "mcp" as const, + transcriptAnchor: { + throughChangeSeq: 11, + digest: hash("2"), + entryIds: modelEntries, + }, + }; + expect( + store.decideAgentOperation({ + op: "admit", + identity: { + ...mcpBase, + operationId: "operation-skipped", + toolUseEntryId: "tool-b", + }, + }), + ).toEqual({ accepted: false, reason: "operation_order" }); + const mcpA = { + ...mcpBase, + operationId: "operation-2", + toolUseEntryId: "tool-a", + }; + expect( + store.decideAgentOperation({ op: "admit", identity: mcpA }).accepted, + ).toBe(true); + expect(store.decideAgentOperation(terminal(mcpA)).accepted).toBe(true); + expect( + store.decideAgentOperation({ + op: "admit", + identity: { + ...mcpBase, + operationId: "operation-duplicate", + toolUseEntryId: "tool-a", + transcriptAnchor: { + throughChangeSeq: 12, + digest: hash("2"), + entryIds: [...modelEntries, "operation-2-output"], + }, + }, + }), + ).toEqual({ accepted: false, reason: "operation_order" }); + + const mcpB = { + ...mcpBase, + operationId: "operation-3", + toolUseEntryId: "tool-b", + transcriptAnchor: { + throughChangeSeq: 12, + digest: hash("3"), + entryIds: [...modelEntries, "operation-2-output"], + }, + }; + expect( + store.decideAgentOperation({ op: "admit", identity: mcpB }).accepted, + ).toBe(true); + expect(store.decideAgentOperation(terminal(mcpB)).accepted).toBe(true); + + const nextModel = { + ...identity, + operationId: "operation-4", + payloadDigest: hash("4"), + transcriptAnchor: { + throughChangeSeq: 13, + digest: hash("4"), + entryIds: [...modelEntries, "operation-3-output"], + }, + }; + expect( + store.decideAgentOperation({ op: "admit", identity: nextModel }), + ).toEqual({ + accepted: false, + reason: "transcript_barrier", + }); + nextModel.transcriptAnchor.entryIds.push("operation-2-output"); + expect( + store.decideAgentOperation({ op: "admit", identity: nextModel }).accepted, + ).toBe(true); + store.close(); + }); + test("indeterminate is terminal, exactly replayable and blocks continuation", () => { + const { store, identity } = fixture(); + store.decideAgentOperation({ op: "admit", identity }); + const request = terminal(identity, "indeterminate"); + const first = store.decideAgentOperation(request); + expect(first.accepted).toBe(true); + expect(store.decideAgentOperation(request)).toEqual( + first.accepted ? { ...first, replayed: true } : first, + ); + expect( + store.decideAgentOperation({ + op: "admit", + identity: { + ...identity, + operationId: "operation-2", + kind: "mcp", + toolUseEntryId: "tool-a", + }, + }), + ).toEqual({ accepted: false, reason: "indeterminate_turn" }); + expect(store.decideAgentOperation(terminal(identity))).toEqual({ + accepted: false, + reason: "operation_barrier", + }); + expect(store.quarantinedSession("session-1")?.reason).toContain( + "terminal receipt crossover", + ); + }); + test("continues a declared tool batch exactly across actor-store restart", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-op-batch-")); + const path = join(dir, "kernel.sqlite"); + const setup = fixture(path); + setup.store.decideAgentOperation({ op: "admit", identity: setup.identity }); + setup.store.decideAgentOperation( + terminal(setup.identity, "settle", ["tool-a", "tool-b"]), + ); + setup.store.close(); + + const reopened = new SessionKernelStore(path); + const mcp = { + ...setup.identity, + operationId: "operation-2", + kind: "mcp" as const, + toolUseEntryId: "tool-a", + transcriptAnchor: { + throughChangeSeq: 11, + digest: hash("2"), + entryIds: ["operation-1-output", "tool-a", "tool-b"], + }, + }; + expect( + reopened.decideAgentOperation({ op: "admit", identity: mcp }).accepted, + ).toBe(true); + reopened.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + test("fails closed and quarantines runtime receipt tampering", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-op-tamper-")); + const path = join(dir, "kernel.sqlite"); + const setup = fixture(path); + setup.store.decideAgentOperation({ op: "admit", identity: setup.identity }); + const db = new Database(path); + db.run( + "UPDATE session_kernel_agent_operations SET receipt='{}' WHERE session_id='session-1'", + ); + db.close(); + expect( + setup.store.decideAgentOperation({ + op: "query", + identity: setup.identity, + }), + ).toEqual({ accepted: false, reason: "operation_barrier" }); + expect(setup.store.quarantinedSession("session-1")?.reason).toContain( + "Corrupt or contradictory", + ); + const unrelatedIdentity = { + ...setup.identity, + operationId: "operation-unrelated", + }; + expect( + setup.store.decideAgentOperation({ + op: "query", + identity: unrelatedIdentity, + }), + ).toEqual({ accepted: false, reason: "operation_barrier" }); + expect(setup.store.releaseQuarantine("session-1")).toBe(false); + setup.store.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + test("admission rejects contradictory active signed authority bytes", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-op-authority-tamper-")); + const path = join(dir, "kernel.sqlite"); + const setup = fixture(path); + const db = new Database(path); + db.run( + "UPDATE session_kernel_agent_host_supervision SET authority_bytes='dGFtcGVy' WHERE session_id='session-1'", + ); + db.close(); + expect(() => + setup.store.decideAgentOperation({ + op: "admit", + identity: setup.identity, + }), + ).toThrow("Contradictory durable Agent Host authority"); + setup.store.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + test("strict startup scan rejects denormalized receipt contradictions", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-op-startup-tamper-")); + const path = join(dir, "kernel.sqlite"); + const setup = fixture(path); + setup.store.decideAgentOperation({ op: "admit", identity: setup.identity }); + setup.store.close(); + const db = new Database(path); + db.run( + "UPDATE session_kernel_agent_operations SET admitted_at=admitted_at+1 WHERE session_id='session-1'", + ); + db.close(); + expect(() => new SessionKernelStore(path)).toThrow( + "contradicts its receipt identity", + ); + rmSync(dir, { recursive: true, force: true }); + }); + + test("strict schema validation rejects a missing required index", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-op-schema-tamper-")); + const path = join(dir, "kernel.sqlite"); + const setup = fixture(path); + setup.store.close(); + const db = new Database(path); + db.exec("DROP INDEX idx_skao_prune"); + db.close(); + expect(() => new SessionKernelStore(path)).toThrow( + "schema indexes do not match exact schema 28", + ); + rmSync(dir, { recursive: true, force: true }); + }); + + test("strict startup scan rejects a missing operation high-water row", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-op-high-water-tamper-")); + const path = join(dir, "kernel.sqlite"); + const setup = fixture(path); + setup.store.decideAgentOperation({ op: "admit", identity: setup.identity }); + setup.store.close(); + const db = new Database(path); + db.run( + "DELETE FROM session_kernel_agent_operation_high_water WHERE session_id='session-1'", + ); + db.close(); + expect(() => new SessionKernelStore(path)).toThrow( + "high-water contradicts receipts", + ); + rmSync(dir, { recursive: true, force: true }); + }); + + test("strict startup scan rejects unsafe orphan high-water values", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-op-high-water-overflow-")); + const path = join(dir, "kernel.sqlite"); + const setup = fixture(path); + setup.store.close(); + const db = new Database(path); + db.run( + `INSERT INTO session_kernel_agent_operation_high_water + (session_id,operation_sequence,updated_at) + VALUES ('orphan-session',9007199254740992,0)`, + ); + db.close(); + expect(() => new SessionKernelStore(path)).toThrow( + "Invalid Agent operation high-water", + ); + rmSync(dir, { recursive: true, force: true }); + }); + + test("persists sequence across restart and removes operation state with deletion", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-op-")); + const path = join(dir, "kernel.sqlite"); + let setup = fixture(path); + expect( + setup.store.decideAgentOperation({ + op: "admit", + identity: setup.identity, + }).accepted, + ).toBe(true); + setup.store.close(); + const reopened = new SessionKernelStore(path); + expect( + reopened.decideAgentOperation({ op: "query", identity: setup.identity }) + .accepted, + ).toBe(true); + reopened.tombstoneSession("session-1"); + expect(() => + reopened.decideAgentOperation({ op: "query", identity: setup.identity }), + ).toThrow(); + reopened.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + test("strictly decodes cancellation requests and rejects accessors and extra keys", () => { + const { store, identity } = fixture(); + const valid = { + op: "cancel", + identity, + cancelId: "cancel-1", + reason: "user", + } as const; + expect(decodeAgentOperationRequest(valid)).toEqual(valid); + expect( + decodeAgentOperationRequest({ ...valid, extra: true }), + ).toBeUndefined(); + expect( + decodeAgentOperationRequest({ ...valid, reason: "timeout" }), + ).toBeUndefined(); + const accessor = { ...valid } as Record; + Object.defineProperty(accessor, "cancelId", { + enumerable: true, + get: () => "cancel-1", + }); + expect(decodeAgentOperationRequest(accessor)).toBeUndefined(); + store.close(); + }); + + test("persists exact requested intent before terminal settlement and fails conflicts closed", () => { + const { store, identity } = fixture(); + expect(store.decideAgentOperation({ op: "admit", identity }).accepted).toBe( + true, + ); + const before = Date.now(); + const request = { + op: "cancel", + identity, + cancelId: "cancel-1", + reason: "user", + } as const; + const first = store.decideAgentOperation(request); + expect(first).toMatchObject({ + accepted: true, + replayed: false, + intent: { + cancelId: "cancel-1", + reason: "user", + disposition: "requested", + }, + }); + if (first.accepted) + expect(first.intent.requestedAtMs).toBeGreaterThanOrEqual(before); + expect(store.decideAgentOperation(request)).toEqual( + first.accepted ? { ...first, replayed: true } : first, + ); + expect(store.agentOperationCancellationIntent(identity)).toEqual( + first.accepted ? first.intent : undefined, + ); + expect(store.decideAgentOperation(terminal(identity)).accepted).toBe(true); + const terminalReceipt = store.decideAgentOperation({ + op: "query", + identity, + }); + expect(terminalReceipt.accepted && terminalReceipt.receipt.state).toBe( + "settled", + ); + expect( + store.decideAgentOperation({ + ...request, + cancelId: "cancel-2", + }), + ).toEqual({ accepted: false, reason: "operation_barrier" }); + expect(store.quarantinedSession(identity.sessionId)?.reason).toContain( + "crossover", + ); + store.close(); + }); + + test("durably records too_late without changing an existing terminal receipt", () => { + const { store, identity } = fixture(); + store.decideAgentOperation({ op: "admit", identity }); + const settled = store.decideAgentOperation(terminal(identity)); + const cancelled = store.decideAgentOperation({ + op: "cancel", + identity, + cancelId: "cancel-late", + reason: "turn_deadline", + }); + expect(cancelled).toMatchObject({ + accepted: true, + replayed: false, + intent: { disposition: "too_late" }, + }); + expect(store.decideAgentOperation({ op: "query", identity })).toEqual( + settled.accepted ? { ...settled, replayed: true } : settled, + ); + store.close(); + }); + + test("migrates schema 31 and recovers exact cancellation intent after restart", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-op-cancel-restart-")); + const path = join(dir, "kernel.sqlite"); + const setup = fixture(path); + setup.store.decideAgentOperation({ op: "admit", identity: setup.identity }); + const cancellation = setup.store.decideAgentOperation({ + op: "cancel", + identity: setup.identity, + cancelId: "cancel-restart", + reason: "shutdown", + }); + setup.store.close(); + const reopened = new SessionKernelStore(path); + expect(reopened.agentOperationCancellationIntent(setup.identity)).toEqual( + cancellation.accepted ? cancellation.intent : undefined, + ); + reopened.close(); + + const legacy = new Database(path); + legacy.exec( + `DROP TABLE session_kernel_agent_operation_cancellations; PRAGMA user_version = 31`, + ); + legacy.close(); + const migrated = new SessionKernelStore(path); + expect(migrated.stats().schemaVersion).toBe(32); + migrated.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + test("quarantines tampered cancellation evidence and isolates unrelated operations", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-op-cancel-tamper-")); + const path = join(dir, "kernel.sqlite"); + const setup = fixture(path); + setup.store.decideAgentOperation({ op: "admit", identity: setup.identity }); + setup.store.decideAgentOperation({ + op: "cancel", + identity: setup.identity, + cancelId: "cancel-tamper", + reason: "reconnect_deadline", + }); + const unrelated = { ...setup.identity, operationId: "operation-unrelated" }; + const unrelatedSession = { ...setup.identity, sessionId: "session-unrelated" }; + expect( + setup.store.agentOperationCancellationIntent(unrelated), + ).toBeUndefined(); + expect( + setup.store.agentOperationCancellationIntent(unrelatedSession), + ).toBeUndefined(); + expect(setup.store.quarantinedSession("session-unrelated")).toBeUndefined(); + const tamper = new Database(path); + tamper.run( + `UPDATE session_kernel_agent_operation_cancellations SET reason='user' + WHERE session_id=? AND operation_id=?`, + [setup.identity.sessionId, setup.identity.operationId], + ); + tamper.close(); + expect( + setup.store.agentOperationCancellationIntent(setup.identity), + ).toBeUndefined(); + expect( + setup.store.quarantinedSession(setup.identity.sessionId)?.reason, + ).toContain("cancellation"); + setup.store.clearSession(setup.identity.sessionId); + const inspect = new Database(path, { readonly: true }); + expect( + ( + inspect + .query( + "SELECT COUNT(*) AS count FROM session_kernel_agent_operation_cancellations", + ) + .get() as { count: number } + ).count, + ).toBe(0); + inspect.close(); + setup.store.close(); + rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/packages/core/opensession-server/src/server/session-kernel/index.ts b/packages/core/opensession-server/src/server/session-kernel/index.ts index 96382e57d4..f10af2b66c 100644 --- a/packages/core/opensession-server/src/server/session-kernel/index.ts +++ b/packages/core/opensession-server/src/server/session-kernel/index.ts @@ -5,6 +5,7 @@ export * from "./runtime"; export * from "./actor-runtime"; export * from "./delivery-map"; export * from "./delivery-protocol"; +export * from "./agent-operation-protocol"; export * from "./turn-protocol"; export * from "./transcript-protocol"; export * from "./timer-protocol"; diff --git a/packages/core/opensession-server/src/server/session-kernel/kernel.ts b/packages/core/opensession-server/src/server/session-kernel/kernel.ts index 284b91a809..abc425b3a3 100644 --- a/packages/core/opensession-server/src/server/session-kernel/kernel.ts +++ b/packages/core/opensession-server/src/server/session-kernel/kernel.ts @@ -7,6 +7,13 @@ */ import { audit } from "../audit"; import type { AskActorRequest, AskActorResult } from "./ask-protocol"; +import { decodeAgentOperationRequest, type AgentOperationRequest, type AgentOperationResult } from "./agent-operation-protocol"; +import type { + AgentHostPlanRegistration, + AgentHostPlanRegistrationResult, + AgentHostSupervisionClaim, + AgentHostSupervisionResult, +} from "./agent-host-supervision-protocol"; import { type SessionActorEffectFor, type SessionActorEffectKind, @@ -80,6 +87,20 @@ function compatibilityStoreForTest( return __sessionKernelStoreForTest(); } +export async function registerAgentHostPlan( + request: AgentHostPlanRegistration, +): Promise { + if (state.actor) return state.actor.decideAgentHostSupervisionAsync(request); + return compatibilityStoreForTest("core").registerAgentHostPlan(request); +} + +export async function claimAgentHostSupervision( + request: AgentHostSupervisionClaim, +): Promise { + if (state.actor) return state.actor.decideAgentHostSupervisionAsync(request); + return compatibilityStoreForTest("core").claimAgentHostSupervision(request); +} + export async function sessionAsk( request: T, ): Promise> { diff --git a/packages/core/opensession-server/src/server/session-kernel/lifecycle-protocol.ts b/packages/core/opensession-server/src/server/session-kernel/lifecycle-protocol.ts index d55e313de8..457bba60d3 100644 --- a/packages/core/opensession-server/src/server/session-kernel/lifecycle-protocol.ts +++ b/packages/core/opensession-server/src/server/session-kernel/lifecycle-protocol.ts @@ -1,4 +1,6 @@ import type { AskActorRequest } from "./ask-protocol"; +import type { AgentOperationRequest } from "./agent-operation-protocol"; +import type { AgentHostSupervisionRequest } from "./agent-host-supervision-protocol"; import type { DeliveryActorRequest } from "./delivery-protocol"; import type { GatewayCommandRequest } from "./gateway-command-protocol"; import type { CoreActorRequest } from "./core-protocol"; @@ -17,6 +19,16 @@ export type RunFence = { }; export type SessionActorReducerCommand = + | { + kind: "agent_operation"; + commandId: string; + request: AgentOperationRequest; + } + | { + kind: "agent_host_supervision"; + commandId: string; + request: AgentHostSupervisionRequest; + } | { kind: "creation_event"; commandId: string; diff --git a/packages/core/opensession-server/src/server/session-kernel/store-routing.ts b/packages/core/opensession-server/src/server/session-kernel/store-routing.ts index 66c93cc647..2ff943aa29 100644 --- a/packages/core/opensession-server/src/server/session-kernel/store-routing.ts +++ b/packages/core/opensession-server/src/server/session-kernel/store-routing.ts @@ -66,6 +66,7 @@ const SESSION_FIRST_METHODS = new Set([ ]); const SESSION_INPUT_METHODS = new Set([ + "agentOperationCancellationIntent", "acceptCommand", "completeCommandDecision", "setRunState", diff --git a/packages/core/opensession-server/src/server/session-kernel/store.ts b/packages/core/opensession-server/src/server/session-kernel/store.ts index 6c3877601f..f1fa028f87 100644 --- a/packages/core/opensession-server/src/server/session-kernel/store.ts +++ b/packages/core/opensession-server/src/server/session-kernel/store.ts @@ -3,6 +3,40 @@ import { GATEWAY_COMMAND_OPERATIONS, } from "./gateway-command-protocol"; import { decodeExecutorId } from "@tellahq/opensession-protocol/executor"; +import { + MAX_AGENT_HOST_SUPERVISION_CLOCK_SKEW_MS, + decodeAgentHostSupervisionAuthorityV2, + serializeAgentHostSupervisionAuthorityV2, +} from "@tellahq/opensession-protocol/agent-host"; +import { + authorityFromAgentHostSupervisionClaim, + decodeAgentHostPlanRegistration, + decodeAgentHostSupervisionClaim, + type AgentHostPlanRegistration, + type AgentHostPlanRegistrationResult, + type AgentHostSupervisionClaim, + type AgentHostSupervisionIssuerContext, + type AgentHostSupervisionReceipt, + type AgentHostSupervisionResult, +} from "./agent-host-supervision-protocol"; +import { decodeSignedAgentHostSupervisionEnvelopeV1 } from "@tellahq/opensession-protocol/agent-host-supervision"; +import { + AGENT_OPERATION_EVIDENCE_HORIZON_MS, + SESSION_KERNEL_MAX_AGENT_OPERATIONS_PER_SESSION, + SESSION_KERNEL_MAX_AGENT_OPERATIONS_PER_TURN, + canonicalAgentOperationIdentity, + canonicalAgentOperationTerminal, + decodeAgentOperationCancellationIntent, + decodeAgentOperationReceipt, + decodeAgentOperationRequest, + type AgentOperationCancel, + type AgentOperationCancellationIntent, + type AgentOperationCancellationResult, + type AgentOperationIdentity, + type AgentOperationReceipt, + type AgentOperationRequest, + type AgentOperationResult, +} from "./agent-operation-protocol"; /** * Durable state for the session actor boundary. * @@ -240,6 +274,7 @@ const PROCESS_OWNER_ID = (ownerGlobal.__opensessionSessionKernelOwnerId ??= start: linuxProcessStart(process.pid), } satisfies ProcessOwnerIdentity)); export const SESSION_KERNEL_SCHEMA_VERSION = 32; +export const SESSION_KERNEL_MAX_AGENT_HOST_SUPERVISION_RECEIPTS = 64; export const SESSION_KERNEL_MAX_CREATION_EFFECT_RECEIPTS = 256; export const SESSION_KERNEL_MAX_OPENING_PLAN_BYTES = 16 * 1024 * 1024; @@ -314,59 +349,6 @@ export function sessionKernelSessionDbPath( return `${root}/${key.slice(0, 2)}/${key}.sqlite`; } -/** - * Transcript-destination fence surface, retained after the Agent Host program - * revert because live per-session kernel DBs and the surviving transcript - * actor destination-append path still use the plan fence. - */ -export type AgentHostPlanRegistration = { - op: "register_plan"; - registrationId: string; - sessionId: string; - runId: string; - turnId: string; - generation: number; - planHash: string; -}; -export type AgentHostPlanRegistrationResult = - | { accepted: true; replayed: boolean } - | { - accepted: false; - reason: "stale_run" | "terminal_run" | "invalid_plan" | "plan_mismatch"; - }; -const PLAN_KEYS = [ - "op", - "registrationId", - "sessionId", - "runId", - "turnId", - "generation", - "planHash", -] as const; -const PLAN_HASH_RE = /^sha256:[a-f0-9]{64}$/; -export function decodeAgentHostPlanRegistration( - value: unknown, -): AgentHostPlanRegistration | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) - return undefined; - const plan = value as Record; - if ( - Object.keys(plan).length !== PLAN_KEYS.length || - Object.keys(plan).some((key) => !PLAN_KEYS.includes(key as never)) || - plan.op !== "register_plan" || - !decodeExecutorId(plan.registrationId) || - !decodeExecutorId(plan.sessionId) || - !decodeExecutorId(plan.runId) || - !decodeExecutorId(plan.turnId) || - !Number.isSafeInteger(plan.generation) || - (plan.generation as number) < 0 || - typeof plan.planHash !== "string" || - !PLAN_HASH_RE.test(plan.planHash) - ) - return undefined; - return plan as AgentHostPlanRegistration; -} - type DurableAgentHostPlan = AgentHostPlanRegistration & { hostId?: string; hostGenerationHighWater: number; @@ -406,6 +388,49 @@ function decodeDurableAgentHostPlan( }; } +type DurableAgentHostSupervisionRow = { + session_id: string; + supervisor_epoch: number; + run_id: string; + run_generation: number; + host_id: string; + host_generation: number; + host_incarnation: string; + kernel_service_epoch: string; + challenge: string; + nonce: string; + status: "active" | "superseded" | "settled"; + authority: string; + authority_bytes: string; + authority_hash: string; + expires_at: number; +}; + +function decodeDurableAgentHostAuthority( + row: DurableAgentHostSupervisionRow, +) { + const authority = decodeAgentHostSupervisionAuthorityV2(parsed(row.authority)); + if (!authority) + throw new Error("Invalid durable Agent Host authority during migration"); + const bytes = serializeAgentHostSupervisionAuthorityV2(authority); + const text = Buffer.from(bytes).toString("utf8"); + if ( + authority.fence.sessionId !== row.session_id || + authority.fence.runId !== row.run_id || + authority.fence.generation !== row.run_generation || + authority.supervisorEpoch !== row.supervisor_epoch || + authority.hostId !== row.host_id || + authority.hostGeneration !== row.host_generation || + authority.hostIncarnation !== row.host_incarnation || + authority.kernelServiceEpoch !== row.kernel_service_epoch || + authority.hostChallenge !== row.challenge || + authority.nonce !== row.nonce || + Buffer.from(bytes).toString("base64") !== row.authority_bytes || + `sha256:${digest(text)}` !== row.authority_hash + ) throw new Error("Contradictory durable Agent Host authority during migration"); + return authority; +} + function migrateAgentHostSupervisionSchema( db: Database, schemaVersion: number, @@ -430,6 +455,102 @@ function migrateAgentHostSupervisionSchema( db.exec( "ALTER TABLE session_kernel_agent_host_plan ADD COLUMN host_generation_high_water INTEGER NOT NULL DEFAULT 0", ); + + const rows = db.query( + `SELECT session_id, supervisor_epoch, run_id, run_generation, host_id, + host_generation, host_incarnation, kernel_service_epoch, + challenge, nonce, status, authority, authority_bytes, + authority_hash, expires_at + FROM session_kernel_agent_host_supervision + ORDER BY session_id, supervisor_epoch`, + ).all() as DurableAgentHostSupervisionRow[]; + const bySession = new Map; + }>>(); + for (const row of rows) { + const authority = decodeDurableAgentHostAuthority(row); + if (row.expires_at !== authority.expiresAtMs) + db.run( + `UPDATE session_kernel_agent_host_supervision SET expires_at = ? + WHERE session_id = ? AND supervisor_epoch = ?`, + [authority.expiresAtMs, row.session_id, row.supervisor_epoch], + ); + const entries = bySession.get(row.session_id) ?? []; + entries.push({ row, authority }); + bySession.set(row.session_id, entries); + } + + for (const [sessionId, entries] of bySession) { + const hostIds = new Set(entries.map(({ authority }) => authority.hostId)); + if (hostIds.size !== 1) + throw new Error("Contradictory durable Agent Host IDs during migration"); + const active = entries.filter(({ row }) => row.status === "active"); + if (active.length > 1) + throw new Error("Multiple active Agent Host authorities during migration"); + const selected = active[0] ?? entries.at(-1)!; + const supervisorHighWater = Math.max( + ...entries.map(({ authority }) => authority.supervisorEpoch), + ); + const hostGenerationHighWater = Math.max( + ...entries.map(({ authority }) => authority.hostGeneration), + ); + const hostId = selected.authority.hostId; + const prior = decodeDurableAgentHostPlan( + sessionId, + db.query( + `SELECT registration_id, run_id, run_generation, turn_id, plan_hash, + host_id, host_generation_high_water, supervisor_high_water + FROM session_kernel_agent_host_plan WHERE session_id = ?`, + ).get(sessionId) as Record | null, + ); + if (prior) { + if (prior.hostId && prior.hostId !== hostId) + throw new Error("Agent Host plan ID contradicted receipts during migration"); + const sameFence = + prior.runId === selected.authority.fence.runId && + prior.generation === selected.authority.fence.generation; + if ( + (active.length > 0 || sameFence) && + (!sameFence || + prior.turnId !== selected.authority.fence.turnId || + prior.planHash !== selected.authority.planHash) + ) throw new Error("Agent Host plan contradicted receipts during migration"); + db.run( + `UPDATE session_kernel_agent_host_plan + SET host_id = ?, host_generation_high_water = ?, + supervisor_high_water = ?, updated_at = ? + WHERE session_id = ?`, + [ + hostId, + Math.max(prior.hostGenerationHighWater, hostGenerationHighWater), + Math.max(prior.supervisorHighWater, supervisorHighWater), + Date.now(), + sessionId, + ], + ); + } else { + db.run( + `INSERT INTO session_kernel_agent_host_plan + (session_id, registration_id, run_id, run_generation, turn_id, + plan_hash, host_id, host_generation_high_water, + supervisor_high_water, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + sessionId, + `migration:${selected.authority.supervisorEpoch}`, + selected.authority.fence.runId, + selected.authority.fence.generation, + selected.authority.fence.turnId, + selected.authority.planHash, + hostId, + hostGenerationHighWater, + supervisorHighWater, + Date.now(), + ], + ); + } + } db.exec("PRAGMA user_version = 26"); }); tx.immediate(); @@ -480,6 +601,46 @@ function assertAgentOperationSchema28(db: Database): void { if(!Object.values(integrity).includes("ok")) throw new Error("Agent operation schema integrity check failed"); } +type DurableAgentOperationCancellationRow = { + session_id: string; + operation_id: string; + identity_hash: string; + identity: string; + cancel_id: string; + reason: string; + disposition: "requested" | "too_late"; + requested_at: number; + intent: string; +}; + +function decodeDurableAgentOperationCancellationRow( + row: DurableAgentOperationCancellationRow, + operation?: AgentOperationReceipt, +): AgentOperationCancellationIntent { + const intent = decodeAgentOperationCancellationIntent( + parseStrictJson(row.intent), + ); + if (!intent) + throw new Error("Agent operation cancellation row has invalid intent"); + const identityText = canonicalAgentOperationIdentity(intent.identity); + const identityHash = `sha256:${digest(identityText)}`; + if ( + row.session_id !== intent.identity.sessionId || + row.operation_id !== intent.identity.operationId || + row.identity_hash !== identityHash || + row.identity !== identityText || + row.cancel_id !== intent.cancelId || + row.reason !== intent.reason || + row.disposition !== intent.disposition || + Number(row.requested_at) !== intent.requestedAtMs || + row.intent !== json(intent) || + (operation && + canonicalAgentOperationIdentity(operation.identity) !== identityText) + ) + throw new Error("Agent operation cancellation row contradicts its intent"); + return intent; +} + function assertAgentOperationCancellationSchema32(db: Database): void { const row = db .query( @@ -542,6 +703,34 @@ function assertAgentOperationCancellationSchema32(db: Database): void { ); } +function assertAgentOperationCancellationRows32( + db: Database, + sessionId?: string, +): void { + const rows = db + .query( + `SELECT cancel.* FROM session_kernel_agent_operation_cancellations AS cancel + WHERE ? IS NULL OR cancel.session_id=?`, + ) + .all( + sessionId ?? null, + sessionId ?? null, + ) as DurableAgentOperationCancellationRow[]; + for (const row of rows) { + const operationRow = db + .query( + "SELECT * FROM session_kernel_agent_operations WHERE session_id=? AND operation_id=?", + ) + .get(row.session_id, row.operation_id) as DurableAgentOperationRow | null; + if (!operationRow) + throw new Error("Agent operation cancellation has no durable operation"); + decodeDurableAgentOperationCancellationRow( + row, + decodeDurableAgentOperationRow(operationRow), + ); + } +} + function migrateAgentOperationCancellationSchema32( db: Database, schemaVersion: number, @@ -632,6 +821,167 @@ function migrateQuarantineProjectionSchema30( tx.immediate(); } +type DurableAgentOperationRow = { + session_id: string; + operation_id: string; + semantic_hash: string; + identity_hash: string; + identity: string; + operation_sequence: number; + run_id: string; + turn_id: string; + run_generation: number; + kind: "model" | "mcp"; + state: "admitted" | "settled" | "indeterminate"; + anchor_change_seq: number; + terminal_change_seq: number | null; + terminal_entry_ids: string | null; + terminal_request: string | null; + receipt: string; + admitted_at: number; + terminal_at: number | null; +}; + +function parseStrictJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + throw new Error("Agent operation row contains invalid JSON"); + } +} + +function decodeDurableAgentOperationRow( + row: DurableAgentOperationRow, +): AgentOperationReceipt { + const receipt = decodeAgentOperationReceipt(parseStrictJson(row.receipt)); + if (!receipt) throw new Error("Agent operation row has an invalid receipt"); + const identityText = canonicalAgentOperationIdentity(receipt.identity); + const identityHash = `sha256:${digest(identityText)}`; + const { operationId: _operationId, ...semanticIdentity } = receipt.identity; + const semanticHash = `sha256:${digest(JSON.stringify(semanticIdentity))}`; + if ( + row.session_id !== receipt.identity.sessionId || + row.operation_id !== receipt.identity.operationId || + row.identity !== identityText || + row.identity_hash !== identityHash || + row.semantic_hash !== semanticHash || + Number(row.operation_sequence) !== receipt.sequence || + row.run_id !== receipt.identity.runId || + row.turn_id !== receipt.identity.turnId || + Number(row.run_generation) !== receipt.identity.generation || + row.kind !== receipt.identity.kind || + row.state !== receipt.state || + Number(row.anchor_change_seq) !== + receipt.identity.transcriptAnchor.throughChangeSeq || + Number(row.admitted_at) !== receipt.admittedAtMs + ) + throw new Error("Agent operation row contradicts its receipt identity"); + + if (receipt.state === "admitted") { + if ( + row.terminal_change_seq !== null || + row.terminal_entry_ids !== null || + row.terminal_request !== null || + row.terminal_at !== null + ) + throw new Error("Admitted Agent operation row contains terminal fields"); + return receipt; + } + if (row.terminal_request === null || row.terminal_entry_ids === null) { + throw new Error("Terminal Agent operation row is incomplete"); + } + const request = decodeAgentOperationRequest( + parseStrictJson(row.terminal_request), + ); + if (!request || (request.op !== "settle" && request.op !== "indeterminate")) { + throw new Error("Agent operation terminal request is invalid"); + } + const entryIds = [ + ...new Set(request.transcriptReceipts.flatMap((ref) => ref.entryIds)), + ]; + const storedEntryIds = parseStrictJson(row.terminal_entry_ids); + const terminalChangeSeq = Math.max( + ...request.transcriptReceipts.map((ref) => ref.throughChangeSeq), + ); + if ( + canonicalAgentOperationTerminal(request) !== row.terminal_request || + canonicalAgentOperationIdentity(request.identity) !== identityText || + (request.op === "settle" ? "settled" : "indeterminate") !== receipt.state || + JSON.stringify(storedEntryIds) !== JSON.stringify(entryIds) || + Number(row.terminal_change_seq) !== terminalChangeSeq || + Number(row.terminal_at) !== receipt.terminalAtMs || + request.gatewayReceiptDigest !== receipt.gatewayReceiptDigest || + request.outputDigest !== receipt.outputDigest || + request.outcomeCode !== receipt.outcomeCode || + JSON.stringify(request.transcriptReceipts) !== + JSON.stringify(receipt.transcriptReceipts) || + JSON.stringify(request.pendingToolUseEntryIds) !== + JSON.stringify(receipt.pendingToolUseEntryIds) + ) + throw new Error("Agent operation row contradicts its terminal receipt"); + return receipt; +} + +function assertAgentOperationRows28(db: Database, sessionId?: string): void { + const rows = db + .query( + `SELECT * FROM session_kernel_agent_operations + WHERE ? IS NULL OR session_id=?`, + ) + .all(sessionId ?? null, sessionId ?? null) as DurableAgentOperationRow[]; + for (const row of rows) decodeDurableAgentOperationRow(row); + const allHighs = db + .query( + `SELECT session_id,operation_sequence,updated_at + FROM session_kernel_agent_operation_high_water + WHERE ? IS NULL OR session_id=?`, + ) + .all(sessionId ?? null, sessionId ?? null) as Array<{ + session_id: string; + operation_sequence: number; + updated_at: number; + }>; + for (const high of allHighs) { + if ( + typeof high.session_id !== "string" || + high.session_id.length === 0 || + !Number.isSafeInteger(high.operation_sequence) || + high.operation_sequence < 0 || + high.operation_sequence >= Number.MAX_SAFE_INTEGER || + !Number.isSafeInteger(high.updated_at) || + high.updated_at < 0 + ) + throw new Error( + `Invalid Agent operation high-water for ${high.session_id}`, + ); + } + const highs = db + .query( + `SELECT op.session_id,MAX(op.operation_sequence) AS actual, + high.operation_sequence + FROM session_kernel_agent_operations AS op + LEFT JOIN session_kernel_agent_operation_high_water AS high + ON high.session_id=op.session_id + WHERE ? IS NULL OR op.session_id=? + GROUP BY op.session_id`, + ) + .all(sessionId ?? null, sessionId ?? null) as Array<{ + session_id: string; + operation_sequence: number | null; + actual: number; + }>; + for (const high of highs) { + if ( + high.operation_sequence === null || + Number(high.operation_sequence) < Number(high.actual) + ) { + throw new Error( + `Agent operation high-water contradicts receipts for ${high.session_id}`, + ); + } + } +} + function migrateAgentOperationSchema28( db: Database, schemaVersion: number, @@ -794,6 +1144,86 @@ function migrateAgentHostSupervisionSchema27( ): void { if (schemaVersion >= 27) return; const tx = db.transaction(() => { + const rows = db + .query( + `SELECT session_id, supervisor_epoch, claim_id, request_hash, run_id, + run_generation, host_id, host_generation, host_incarnation, + kernel_service_epoch, challenge, nonce, status, authority, + authority_bytes, authority_hash, expires_at, created_at + FROM session_kernel_agent_host_supervision + ORDER BY session_id, supervisor_epoch`, + ) + .all() as Array< + DurableAgentHostSupervisionRow & { + claim_id: string; + request_hash: string; + created_at: number; + } + >; + const sessionState = new Map< + string, + { + active: number; + hostId: string; + supervisor: number; + hostGeneration: number; + activeAuthority?: ReturnType; + } + >(); + for (const row of rows) { + const authority = decodeDurableAgentHostAuthority(row); + if (row.expires_at !== authority.expiresAtMs) + throw new Error( + "Contradictory Agent Host expiry during schema 27 migration", + ); + const state = sessionState.get(row.session_id) ?? { + active: 0, + hostId: row.host_id, + supervisor: 0, + hostGeneration: 0, + }; + if (state.hostId !== row.host_id) + throw new Error("Mixed Agent Host IDs during schema 27 migration"); + if (row.status === "active") { + if (++state.active > 1) + throw new Error( + "Multiple active Agent Host receipts during schema 27 migration", + ); + state.activeAuthority = authority; + } + state.supervisor = Math.max(state.supervisor, authority.supervisorEpoch); + state.hostGeneration = Math.max( + state.hostGeneration, + authority.hostGeneration, + ); + sessionState.set(row.session_id, state); + } + for (const [sessionId, state] of sessionState) { + const plan = decodeDurableAgentHostPlan( + sessionId, + db + .query( + `SELECT registration_id, run_id, run_generation, turn_id, plan_hash, + host_id, host_generation_high_water, supervisor_high_water + FROM session_kernel_agent_host_plan WHERE session_id = ?`, + ) + .get(sessionId) as Record | null, + ); + if ( + !plan || + plan.hostId !== state.hostId || + plan.supervisorHighWater < state.supervisor || + plan.hostGenerationHighWater < state.hostGeneration || + (state.activeAuthority != null && + (plan.runId !== state.activeAuthority.fence.runId || + plan.generation !== state.activeAuthority.fence.generation || + plan.turnId !== state.activeAuthority.fence.turnId || + plan.planHash !== state.activeAuthority.planHash)) + ) + throw new Error( + "Agent Host plan high-water regression during schema 27 migration", + ); + } db.exec(` DROP INDEX IF EXISTS idx_skahs_active; ALTER TABLE session_kernel_agent_host_supervision RENAME TO session_kernel_agent_host_supervision_v26; @@ -912,6 +1342,8 @@ export type SessionKernelStoreOptions = { allocateOutboxId?: (sessionId: string) => number; busyTimeoutMs?: number; hydrateRunStateCache?: boolean; + /** Trusted non-wire issuer. Production deliberately leaves this absent. */ + agentHostSupervisionIssuer?: AgentHostSupervisionIssuerContext; }; const SESSION_KERNEL_SESSION_TABLES = [ @@ -953,10 +1385,12 @@ export class SessionKernelStore { private readonly dirtyChangeSessions = new Set(); private readonly path: string; private readonly allocateOutboxId?: (sessionId: string) => number; + private readonly agentHostSupervisionIssuer?: AgentHostSupervisionIssuerContext; constructor(path = sessionKernelDbPath(), options: SessionKernelStoreOptions = {}) { this.path = path; this.allocateOutboxId = options.allocateOutboxId; + this.agentHostSupervisionIssuer = options.agentHostSupervisionIssuer; const busyTimeoutMs = options.busyTimeoutMs ?? 5_000; if (!Number.isInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > 60_000) throw new Error("Invalid session kernel SQLite busy timeout"); @@ -1450,7 +1884,9 @@ export class SessionKernelStore { migrateTranscriptAuthoritySchema31(this.db, schemaVersion); migrateAgentOperationCancellationSchema32(this.db, schemaVersion); assertAgentOperationSchema28(this.db); + assertAgentOperationRows28(this.db); assertAgentOperationCancellationSchema32(this.db); + assertAgentOperationCancellationRows32(this.db); if (path !== ":memory:") { try { chmodSync(path, 0o600); @@ -1682,6 +2118,14 @@ export class SessionKernelStore { pendingEffects.length > 0 && pendingEffects.every((effect) => recoverableLifecycleEffects.has(effect.kind)); if (pendingEffects.length > 0 && !onlyRecoverableLifecycleEffects) return false; + if (commandKind === "agent_operation") { + try { + assertAgentOperationRows28(this.db, sessionId); + assertAgentOperationCancellationRows32(this.db, sessionId); + } catch { + return false; + } + } return true; } @@ -2402,6 +2846,757 @@ export class SessionKernelStore { return { accepted: true, replayed: false }; } + claimAgentHostSupervision(input: AgentHostSupervisionClaim): AgentHostSupervisionResult { + if (!decodeAgentHostSupervisionClaim(input)) + return { accepted: false, reason: "invalid_claim" }; + if (this.isTombstoned(input.sessionId)) + throw new Error(`Session ${input.sessionId} was deleted`); + const issuer = this.agentHostSupervisionIssuer; + const requestText = JSON.stringify([ + input.op, input.claimId, input.sessionId, input.runId, input.turnId, + input.generation, input.planHash, input.hostId, input.hostGeneration, + input.hostIncarnation, input.hostChallenge, + ]); + const requestHash = `sha256:${digest(requestText)}`; + let result!: AgentHostSupervisionResult; + const tx = this.db.transaction(() => { + const existing = this.db.query( + `SELECT request_hash, receipt_format, key_id, signature, envelope, + authority, authority_bytes, authority_hash + FROM session_kernel_agent_host_supervision + WHERE session_id = ? AND claim_id = ?`, + ).get(input.sessionId, input.claimId) as Record | null; + if (existing) { + if (existing.request_hash !== requestHash) { + result = { accepted: false, reason: "claim_mismatch" }; + return; + } + if (existing.receipt_format !== "signed_v1") { + result = { accepted: false, reason: "issuer_unavailable" }; + return; + } + const authority = decodeAgentHostSupervisionAuthorityV2(parsed(existing.authority as string)); + const envelope = decodeSignedAgentHostSupervisionEnvelopeV1(parsed(existing.envelope as string)); + if (!authority || !envelope) throw new Error("Corrupt durable signed Agent Host receipt"); + const bytes = Buffer.from(serializeAgentHostSupervisionAuthorityV2(authority)); + const standard = bytes.toString("base64"); + const hash = `sha256:${digest(bytes.toString("utf8"))}`; + if (existing.authority !== json(authority) || existing.envelope !== json(envelope) || + existing.authority_bytes !== standard || existing.authority_hash !== hash || + existing.key_id !== authority.keyId || existing.signature !== envelope.signature || + !Buffer.from(envelope.authorityBytes, "base64url").equals(bytes)) + throw new Error("Contradictory durable signed Agent Host receipt"); + result = { accepted: true, replayed: true, receipt: { + format: "signed_v1", authority, authorityBytes: standard, + authorityHash: hash, keyId: authority.keyId, envelope, + }}; + return; + } + + if (!issuer) { + result = { accepted: false, reason: "issuer_unavailable" }; + return; + } + const now = issuer.now(); + if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(issuer.leaseMs) || + issuer.leaseMs <= 0 || issuer.leaseMs > 5 * 60_000) + throw new Error("Invalid trusted Agent Host issuer context"); + const run = this.runState(input.sessionId); + if (run.currentRunId !== input.runId || run.generation !== input.generation) { + result = { accepted: false, reason: "stale_run" }; return; + } + if (!["starting", "running", "ask_blocked", "interrupted", "reattaching"].includes(run.state)) { + result = { accepted: false, reason: "terminal_run" }; return; + } + const plan = decodeDurableAgentHostPlan(input.sessionId, this.db.query( + `SELECT registration_id, run_id, run_generation, turn_id, plan_hash, + host_id, host_generation_high_water, supervisor_high_water + FROM session_kernel_agent_host_plan WHERE session_id = ?`, + ).get(input.sessionId) as Record | null); + if (!plan) { result = { accepted: false, reason: "plan_unregistered" }; return; } + if (plan.runId !== input.runId || plan.generation !== input.generation || + plan.turnId !== input.turnId || plan.planHash !== input.planHash) { + result = { accepted: false, reason: "plan_mismatch" }; return; + } + if ((plan.hostId != null && plan.hostId !== input.hostId) || + input.hostGeneration < plan.hostGenerationHighWater) { + result = { accepted: false, reason: "stale_host" }; return; + } + const current = this.db.query( + `SELECT host_id, host_generation, authority FROM session_kernel_agent_host_supervision + WHERE session_id = ? AND status = 'active'`, + ).get(input.sessionId) as Record | null; + if (current && !decodeAgentHostSupervisionAuthorityV2(parsed(current.authority as string))) + throw new Error("Invalid durable active Agent Host authority"); + if (current && (input.hostId !== current.host_id || input.hostGeneration < Number(current.host_generation))) { + result = { accepted: false, reason: "stale_host" }; return; + } + if (this.db.query("SELECT 1 FROM session_kernel_agent_host_supervision WHERE session_id=? AND challenge=?") + .get(input.sessionId, input.hostChallenge)) { + result = { accepted: false, reason: "challenge_reused" }; return; + } + const nonce = issuer.nonce(); + if (typeof nonce !== "string" || !/^[A-Za-z0-9_-]{16,256}$/.test(nonce)) + throw new Error("Invalid trusted Agent Host nonce"); + if (this.db.query("SELECT 1 FROM session_kernel_agent_host_supervision WHERE session_id=? AND nonce=?") + .get(input.sessionId, nonce)) { + result = { accepted: false, reason: "nonce_reused" }; return; + } + this.db.run( + `DELETE FROM session_kernel_agent_host_supervision + WHERE session_id=? AND status!='active' AND expires_at + ? <= ?`, + [input.sessionId, MAX_AGENT_HOST_SUPERVISION_CLOCK_SKEW_MS, now], + ); + const count = Number((this.db.query( + "SELECT COUNT(*) AS count FROM session_kernel_agent_host_supervision WHERE session_id=?", + ).get(input.sessionId) as { count: number }).count); + if (count >= SESSION_KERNEL_MAX_AGENT_HOST_SUPERVISION_RECEIPTS) { + result = { accepted: false, reason: "receipt_capacity" }; return; + } + const supervisorEpoch = plan.supervisorHighWater + 1; + const authority = authorityFromAgentHostSupervisionClaim(input, { + supervisorEpoch, kernelServiceEpoch: issuer.kernelServiceEpoch, + issuedAtMs: now, expiresAtMs: now + issuer.leaseMs, + nonce, keyId: issuer.keyId, + }); + if (!authority) throw new Error("Trusted issuer constructed invalid Agent Host authority"); + const bytes = Buffer.from(serializeAgentHostSupervisionAuthorityV2(authority)); + const envelope = issuer.sign(bytes, now); + const decodedEnvelope = decodeSignedAgentHostSupervisionEnvelopeV1(envelope); + if (!decodedEnvelope || decodedEnvelope.authorityBytes !== bytes.toString("base64url")) + throw new Error("Agent Host signer returned a contradictory envelope"); + const receipt: AgentHostSupervisionReceipt = { + format: "signed_v1", authority, authorityBytes: bytes.toString("base64"), + authorityHash: `sha256:${digest(bytes.toString("utf8"))}`, + keyId: authority.keyId, envelope: decodedEnvelope, + }; + this.db.run("UPDATE session_kernel_agent_host_supervision SET status='superseded' WHERE session_id=? AND status='active'", [input.sessionId]); + this.db.run( + `INSERT INTO session_kernel_agent_host_supervision + (session_id,supervisor_epoch,claim_id,request_hash,run_id,run_generation, + host_id,host_generation,host_incarnation,kernel_service_epoch,challenge,nonce, + status,receipt_format,key_id,signature,envelope,authority,authority_bytes, + authority_hash,expires_at,created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'active','signed_v1',?,?,?,?,?,?,?,?)`, + [input.sessionId, supervisorEpoch, input.claimId, requestHash, input.runId, + input.generation, input.hostId, input.hostGeneration, input.hostIncarnation, + authority.kernelServiceEpoch, input.hostChallenge, nonce, authority.keyId, + decodedEnvelope.signature, json(decodedEnvelope), json(authority), + receipt.authorityBytes, receipt.authorityHash, authority.expiresAtMs, now], + ); + const update = this.db.run( + `UPDATE session_kernel_agent_host_plan SET host_id=COALESCE(host_id,?), + host_generation_high_water=?, supervisor_high_water=?, updated_at=? + WHERE session_id=? AND run_id=? AND run_generation=?`, + [input.hostId, Math.max(plan.hostGenerationHighWater, input.hostGeneration), + supervisorEpoch, now, input.sessionId, input.runId, input.generation], + ); + if (update.changes !== 1) throw new Error("Agent Host plan changed during signed receipt transaction"); + result = { accepted: true, replayed: false, receipt }; + }); + tx.immediate(); + return result; + } + + decideAgentOperation( + raw: T, + ): T extends AgentOperationCancel + ? AgentOperationCancellationResult + : AgentOperationResult; + decideAgentOperation( + raw: AgentOperationRequest, + ): AgentOperationResult | AgentOperationCancellationResult { + const request = decodeAgentOperationRequest(raw); + if (!request) return { accepted: false, reason: "invalid_request" }; + const identity = request.identity; + if (this.isTombstoned(identity.sessionId)) + throw new Error(`Session ${identity.sessionId} was deleted`); + const identityText = canonicalAgentOperationIdentity(identity); + const identityHash = `sha256:${digest(identityText)}`; + const { operationId: _operationId, ...semanticIdentity } = identity; + const semanticHash = `sha256:${digest(JSON.stringify(semanticIdentity))}`; + const now = Date.now(); + let result!: AgentOperationResult | AgentOperationCancellationResult; + const tx = this.db.transaction(() => { + const existingQuarantine = this.db + .query( + "SELECT 1 AS quarantined FROM session_kernel_quarantine WHERE session_id=?", + ) + .get(identity.sessionId); + if (existingQuarantine) { + result = { accepted: false, reason: "operation_barrier" }; + return; + } + const quarantine = (reason: string): void => { + this.db.run( + `INSERT INTO session_kernel_quarantine(session_id,reason,command_kind,quarantined_at) + VALUES (?,?,'agent_operation',?) ON CONFLICT(session_id) DO UPDATE SET + reason=excluded.reason,command_kind=excluded.command_kind,quarantined_at=excluded.quarantined_at`, + [identity.sessionId, reason, now], + ); + }; + const decodeRow = ( + row: DurableAgentOperationRow | null, + ): AgentOperationReceipt | undefined => { + if (!row) return; + try { + return decodeDurableAgentOperationRow(row); + } catch { + quarantine("Corrupt or contradictory Agent operation receipt"); + return; + } + }; + const rawRow = this.db + .query( + `SELECT * FROM session_kernel_agent_operations WHERE session_id=? AND operation_id=?`, + ) + .get( + identity.sessionId, + identity.operationId, + ) as DurableAgentOperationRow | null; + const rowReceipt = decodeRow(rawRow); + if (rawRow && !rowReceipt) { + result = { accepted: false, reason: "operation_barrier" }; + return; + } + const rawSemantic = this.db + .query( + `SELECT * FROM session_kernel_agent_operations WHERE session_id=? AND semantic_hash=?`, + ) + .get( + identity.sessionId, + semanticHash, + ) as DurableAgentOperationRow | null; + const semanticReceipt = decodeRow(rawSemantic); + if (rawSemantic && !semanticReceipt) { + result = { accepted: false, reason: "operation_barrier" }; + return; + } + if ( + (rawRow && + (rawRow.identity_hash !== identityHash || + rawRow.identity !== identityText)) || + (rawSemantic && rawSemantic.operation_id !== identity.operationId) + ) { + quarantine("Agent operation identity crossover"); + result = { accepted: false, reason: "operation_barrier" }; + return; + } + if (request.op === "cancel") { + if (!rawRow || !rowReceipt) { + result = { accepted: false, reason: "not_found" }; + return; + } + const durableCancellation = this.db + .query( + `SELECT * FROM session_kernel_agent_operation_cancellations + WHERE session_id=? AND operation_id=?`, + ) + .get( + identity.sessionId, + identity.operationId, + ) as DurableAgentOperationCancellationRow | null; + if (durableCancellation) { + let intent: AgentOperationCancellationIntent; + try { + intent = decodeDurableAgentOperationCancellationRow( + durableCancellation, + rowReceipt, + ); + } catch { + quarantine( + "Corrupt or contradictory Agent operation cancellation intent", + ); + result = { accepted: false, reason: "operation_barrier" }; + return; + } + if ( + intent.cancelId !== request.cancelId || + intent.reason !== request.reason + ) { + quarantine("Agent operation cancellation intent crossover"); + result = { accepted: false, reason: "operation_barrier" }; + return; + } + result = { accepted: true, replayed: true, intent }; + return; + } + const intent: AgentOperationCancellationIntent = { + identity, + cancelId: request.cancelId, + reason: request.reason, + disposition: + rowReceipt.state === "admitted" ? "requested" : "too_late", + requestedAtMs: now, + }; + this.db.run( + `INSERT INTO session_kernel_agent_operation_cancellations + (session_id,operation_id,identity_hash,identity,cancel_id,reason, + disposition,requested_at,intent) VALUES (?,?,?,?,?,?,?,?,?)`, + [ + identity.sessionId, + identity.operationId, + identityHash, + identityText, + request.cancelId, + request.reason, + intent.disposition, + now, + json(intent), + ], + ); + result = { accepted: true, replayed: false, intent }; + return; + } + if (request.op === "query") { + result = rowReceipt + ? { accepted: true, replayed: true, receipt: rowReceipt } + : { accepted: false, reason: "not_found" }; + return; + } + if (request.op !== "admit") { + if (!rawRow || !rowReceipt) { + result = { accepted: false, reason: "not_found" }; + return; + } + const terminalText = canonicalAgentOperationTerminal(request); + if (rowReceipt.state !== "admitted") { + if (rawRow.terminal_request === terminalText) { + result = { accepted: true, replayed: true, receipt: rowReceipt }; + } else { + quarantine("Agent operation terminal receipt crossover"); + result = { accepted: false, reason: "operation_barrier" }; + } + return; + } + const refs = request.transcriptReceipts; + if ( + refs.some( + (ref, index) => + ref.throughChangeSeq <= + identity.transcriptAnchor.throughChangeSeq || + (index > 0 && + ref.throughChangeSeq < refs[index - 1]!.throughChangeSeq), + ) + ) { + result = { accepted: false, reason: "transcript_barrier" }; + return; + } + const terminalChangeSeq = Math.max( + ...refs.map((ref) => ref.throughChangeSeq), + ); + const entryIds = [...new Set(refs.flatMap((ref) => [...ref.entryIds]))]; + const receipt: AgentOperationReceipt = { + identity, + sequence: rowReceipt.sequence, + state: request.op === "settle" ? "settled" : "indeterminate", + admittedAtMs: rowReceipt.admittedAtMs, + terminalAtMs: now, + gatewayReceiptDigest: request.gatewayReceiptDigest, + outputDigest: request.outputDigest, + outcomeCode: request.outcomeCode, + transcriptReceipts: refs, + ...(identity.kind === "model" + ? { pendingToolUseEntryIds: request.pendingToolUseEntryIds } + : {}), + }; + const update = this.db.run( + `UPDATE session_kernel_agent_operations SET state=?,terminal_change_seq=?, + terminal_entry_ids=?,terminal_request=?,receipt=?,terminal_at=? + WHERE session_id=? AND operation_id=? AND state='admitted'`, + [ + receipt.state, + terminalChangeSeq, + json(entryIds), + terminalText, + json(receipt), + now, + identity.sessionId, + identity.operationId, + ], + ); + if (update.changes !== 1) + throw new Error("Agent operation settlement lost serialization"); + result = { accepted: true, replayed: false, receipt }; + return; + } + if (rowReceipt) { + result = { accepted: true, replayed: true, receipt: rowReceipt }; + return; + } + const run = this.runState(identity.sessionId); + if ( + run.currentRunId !== identity.runId || + run.generation !== identity.generation + ) { + result = { accepted: false, reason: "stale_run" }; + return; + } + if ( + ![ + "starting", + "running", + "ask_blocked", + "interrupted", + "reattaching", + ].includes(run.state) + ) { + result = { accepted: false, reason: "terminal_run" }; + return; + } + const plan = decodeDurableAgentHostPlan( + identity.sessionId, + this.db + .query( + `SELECT registration_id,run_id,run_generation,turn_id,plan_hash,host_id, + host_generation_high_water,supervisor_high_water + FROM session_kernel_agent_host_plan WHERE session_id=?`, + ) + .get(identity.sessionId) as Record | null, + ); + if (!plan) { + result = { accepted: false, reason: "plan_unregistered" }; + return; + } + if ( + plan.runId !== identity.runId || + plan.generation !== identity.generation || + plan.turnId !== identity.turnId || + plan.planHash !== identity.planHash + ) { + result = { accepted: false, reason: "plan_mismatch" }; + return; + } + const authorityRow = this.db + .query( + `SELECT session_id,supervisor_epoch,run_id,run_generation,host_id, + host_generation,host_incarnation,kernel_service_epoch,challenge, + nonce,status,receipt_format,key_id,signature,envelope,authority, + authority_bytes,authority_hash,expires_at + FROM session_kernel_agent_host_supervision + WHERE session_id=? AND status='active'`, + ) + .get(identity.sessionId) as Record | null; + if ( + !authorityRow || + authorityRow.receipt_format !== "signed_v1" || + !authorityRow.envelope + ) { + result = { accepted: false, reason: "authority_inactive" }; + return; + } + const authority = decodeDurableAgentHostAuthority( + authorityRow as unknown as DurableAgentHostSupervisionRow, + ); + const envelope = decodeSignedAgentHostSupervisionEnvelopeV1( + parsed(authorityRow.envelope as string), + ); + const authorityBytes = Buffer.from( + serializeAgentHostSupervisionAuthorityV2(authority), + ); + if ( + !envelope || + authorityRow.authority !== json(authority) || + authorityRow.envelope !== json(envelope) || + authorityRow.key_id !== authority.keyId || + authorityRow.signature !== envelope.signature || + Number(authorityRow.expires_at) !== authority.expiresAtMs || + !Buffer.from(envelope.authorityBytes, "base64url").equals( + authorityBytes, + ) + ) + throw new Error("Contradictory active signed Agent Host authority"); + if ( + Number(authorityRow.expires_at) + + MAX_AGENT_HOST_SUPERVISION_CLOCK_SKEW_MS <= + now + ) { + result = { accepted: false, reason: "authority_inactive" }; + return; + } + if ( + authorityRow.authority_hash !== identity.authorityHash || + Number(authorityRow.supervisor_epoch) !== identity.supervisorEpoch || + authority.supervisorEpoch !== identity.supervisorEpoch || + authority.planHash !== identity.planHash || + authority.fence.sessionId !== identity.sessionId || + authority.fence.runId !== identity.runId || + authority.fence.turnId !== identity.turnId || + authority.fence.generation !== identity.generation || + authority.hostId !== identity.hostId || + authority.hostGeneration !== identity.hostGeneration || + authority.hostIncarnation !== identity.hostIncarnation || + plan.supervisorHighWater !== identity.supervisorEpoch + ) { + result = { accepted: false, reason: "authority_mismatch" }; + return; + } + + const rawPriorRows = this.db + .query( + `SELECT * FROM session_kernel_agent_operations + WHERE session_id=? AND run_id=? AND run_generation=? AND turn_id=? + ORDER BY operation_sequence`, + ) + .all( + identity.sessionId, + identity.runId, + identity.generation, + identity.turnId, + ) as DurableAgentOperationRow[]; + const priorReceipts: AgentOperationReceipt[] = []; + for (const priorRow of rawPriorRows) { + const priorReceipt = decodeRow(priorRow); + if (!priorReceipt) { + result = { accepted: false, reason: "operation_barrier" }; + return; + } + priorReceipts.push(priorReceipt); + } + const prior = priorReceipts.at(-1); + if (prior?.state === "admitted") { + result = { accepted: false, reason: "operation_barrier" }; + return; + } + if (priorReceipts.some((receipt) => receipt.state === "indeterminate")) { + result = { accepted: false, reason: "indeterminate_turn" }; + return; + } + if (!prior) { + if (identity.kind !== "model") { + result = { accepted: false, reason: "operation_order" }; + return; + } + } else { + let lastModelIndex = -1; + for (let index = priorReceipts.length - 1; index >= 0; index--) { + if (priorReceipts[index]!.identity.kind === "model") { + lastModelIndex = index; + break; + } + } + if (lastModelIndex < 0) { + quarantine("Agent operation turn has no model root"); + result = { accepted: false, reason: "operation_barrier" }; + return; + } + const modelReceipt = priorReceipts[lastModelIndex]!; + const pending = modelReceipt.pendingToolUseEntryIds ?? []; + const mcpReceipts = priorReceipts.slice(lastModelIndex + 1); + if ( + mcpReceipts.some((receipt) => receipt.identity.kind !== "mcp") || + mcpReceipts.length > pending.length + ) { + quarantine( + "Agent operation tool-use sequence contradicts model declaration", + ); + result = { accepted: false, reason: "operation_barrier" }; + return; + } + for (let index = 0; index < mcpReceipts.length; index++) { + if (mcpReceipts[index]!.identity.toolUseEntryId !== pending[index]) { + quarantine("Agent operation tool-use identity crossover"); + result = { accepted: false, reason: "operation_barrier" }; + return; + } + } + const nextToolUseEntryId = pending[mcpReceipts.length]; + if (nextToolUseEntryId !== undefined) { + if ( + identity.kind !== "mcp" || + identity.toolUseEntryId !== nextToolUseEntryId + ) { + result = { accepted: false, reason: "operation_order" }; + return; + } + } else if (pending.length === 0 || identity.kind !== "model") { + result = { accepted: false, reason: "operation_order" }; + return; + } + const dependencyReceipts = priorReceipts.slice(lastModelIndex); + const requiredEntryIds = new Set(); + let requiredChangeSeq = 0; + for (const dependency of dependencyReceipts) { + for (const ref of dependency.transcriptReceipts ?? []) { + requiredChangeSeq = Math.max( + requiredChangeSeq, + ref.throughChangeSeq, + ); + for (const entryId of ref.entryIds) requiredEntryIds.add(entryId); + } + } + if ( + identity.transcriptAnchor.throughChangeSeq < requiredChangeSeq || + [...requiredEntryIds].some( + (entryId) => !identity.transcriptAnchor.entryIds.includes(entryId), + ) + ) { + result = { accepted: false, reason: "transcript_barrier" }; + return; + } + } + + const cutoff = now - AGENT_OPERATION_EVIDENCE_HORIZON_MS; + this.db.run( + `DELETE FROM session_kernel_agent_operations AS old + WHERE old.session_id=? AND old.state IN ('settled','indeterminate') AND old.terminal_at < ? + AND NOT (old.run_id=? AND old.run_generation=? AND old.turn_id=?) + AND NOT EXISTS ( + SELECT 1 FROM session_kernel_agent_operations AS live + WHERE live.session_id=old.session_id AND live.run_id=old.run_id + AND live.run_generation=old.run_generation AND live.turn_id=old.turn_id + AND live.state='admitted' + )`, + [ + identity.sessionId, + cutoff, + identity.runId, + identity.generation, + identity.turnId, + ], + ); + this.db.run( + `DELETE FROM session_kernel_agent_operation_cancellations AS cancel + WHERE cancel.session_id=? AND NOT EXISTS ( + SELECT 1 FROM session_kernel_agent_operations AS operation + WHERE operation.session_id=cancel.session_id + AND operation.operation_id=cancel.operation_id + )`, + [identity.sessionId], + ); + const counts = this.db + .query( + `SELECT COUNT(*) AS session_count, + SUM(CASE WHEN run_id=? AND run_generation=? AND turn_id=? THEN 1 ELSE 0 END) AS turn_count + FROM session_kernel_agent_operations WHERE session_id=?`, + ) + .get( + identity.runId, + identity.generation, + identity.turnId, + identity.sessionId, + ) as { session_count: number; turn_count: number | null }; + if ( + Number(counts.session_count) >= + SESSION_KERNEL_MAX_AGENT_OPERATIONS_PER_SESSION || + Number(counts.turn_count ?? 0) >= + SESSION_KERNEL_MAX_AGENT_OPERATIONS_PER_TURN + ) { + result = { accepted: false, reason: "receipt_capacity" }; + return; + } + const high = this.db + .query( + "SELECT operation_sequence FROM session_kernel_agent_operation_high_water WHERE session_id=?", + ) + .get(identity.sessionId) as { operation_sequence: number } | null; + const sequence = Number(high?.operation_sequence ?? 0) + 1; + if (!Number.isSafeInteger(sequence)) + throw new Error("Agent operation sequence high-water overflow"); + this.db.run( + `INSERT INTO session_kernel_agent_operation_high_water(session_id,operation_sequence,updated_at) + VALUES (?,?,?) ON CONFLICT(session_id) DO UPDATE SET + operation_sequence=excluded.operation_sequence,updated_at=excluded.updated_at`, + [identity.sessionId, sequence, now], + ); + const receipt: AgentOperationReceipt = { + identity, + sequence, + state: "admitted", + admittedAtMs: now, + }; + this.db.run( + `INSERT INTO session_kernel_agent_operations(session_id,operation_id,semantic_hash,identity_hash,identity, + operation_sequence,run_id,turn_id,run_generation,kind,state,anchor_change_seq,receipt,admitted_at) + VALUES (?,?,?,?,?,?,?,?,?,?,'admitted',?,?,?)`, + [ + identity.sessionId, + identity.operationId, + semanticHash, + identityHash, + identityText, + sequence, + identity.runId, + identity.turnId, + identity.generation, + identity.kind, + identity.transcriptAnchor.throughChangeSeq, + json(receipt), + now, + ], + ); + result = { accepted: true, replayed: false, receipt }; + }); + tx.immediate(); + return result; + } + agentOperationCancellationIntent( + raw: AgentOperationIdentity, + ): AgentOperationCancellationIntent | undefined { + const query = decodeAgentOperationRequest({ op: "query", identity: raw }); + if (!query || query.op !== "query") + throw new Error("Invalid Agent operation cancellation query identity"); + const identity = query.identity; + const identityText = canonicalAgentOperationIdentity(identity); + let result: AgentOperationCancellationIntent | undefined; + const tx = this.db.transaction(() => { + const quarantine = (reason: string): void => { + this.db.run( + `INSERT INTO session_kernel_quarantine(session_id,reason,command_kind,quarantined_at) + VALUES (?,?,'agent_operation',?) ON CONFLICT(session_id) DO UPDATE SET + reason=excluded.reason,command_kind=excluded.command_kind,quarantined_at=excluded.quarantined_at`, + [identity.sessionId, reason, Date.now()], + ); + }; + const operationRow = this.db + .query( + "SELECT * FROM session_kernel_agent_operations WHERE session_id=? AND operation_id=?", + ) + .get( + identity.sessionId, + identity.operationId, + ) as DurableAgentOperationRow | null; + const cancellationRow = this.db + .query( + `SELECT * FROM session_kernel_agent_operation_cancellations + WHERE session_id=? AND operation_id=?`, + ) + .get( + identity.sessionId, + identity.operationId, + ) as DurableAgentOperationCancellationRow | null; + if (!operationRow) { + if (cancellationRow) + quarantine("Agent operation cancellation has no durable operation"); + return; + } + let receipt: AgentOperationReceipt; + try { + receipt = decodeDurableAgentOperationRow(operationRow); + } catch { + quarantine("Corrupt or contradictory Agent operation receipt"); + return; + } + if (canonicalAgentOperationIdentity(receipt.identity) !== identityText) { + quarantine("Agent operation cancellation query identity crossover"); + return; + } + if (!cancellationRow) return; + try { + result = decodeDurableAgentOperationCancellationRow( + cancellationRow, + receipt, + ); + } catch { + quarantine( + "Corrupt or contradictory Agent operation cancellation intent", + ); + } + }); + tx.immediate(); + return result; + } + applyRunEvent(input: RunEventDecision): RunEventDecisionResult { const now = Date.now(); const since = new Date(now).toISOString(); @@ -2499,6 +3694,12 @@ export class SessionKernelStore { ? input.runKey : prior.currentRunId; const changeSeq = prior.changeSeq + 1; + if (["idle", "stopped", "failed"].includes(to)) + this.db.run( + `UPDATE session_kernel_agent_host_supervision SET status = 'settled' + WHERE session_id = ? AND status = 'active'`, + [input.sessionId], + ); this.db.run( `INSERT INTO session_kernel_state (session_id, run_state, run_since, last_event, generation, diff --git a/packages/core/opensession-server/src/server/session-kernel/transcript-protocol.ts b/packages/core/opensession-server/src/server/session-kernel/transcript-protocol.ts index eda9c4825a..42e06c0606 100644 --- a/packages/core/opensession-server/src/server/session-kernel/transcript-protocol.ts +++ b/packages/core/opensession-server/src/server/session-kernel/transcript-protocol.ts @@ -1,155 +1,9 @@ import type { TranscriptIndexEntry } from "@tellahq/opensession-protocol/session"; -/** - * Transcript-destination wire contracts, relocated here from the removed - * protocol/agent-operation module. The currently deployed release wrote - * destination appends and receipts with these exact shapes, so the surviving - * transcript actor keeps validating and serving them unchanged. - */ -export type AgentOperationDigest = `sha256:${string}`; -export interface AgentTranscriptAnchorV1 { - throughChangeSeq: number; - entryIds: readonly string[]; - digest: AgentOperationDigest; -} -export interface AgentTranscriptReceiptRefV1 { - appendId: string; - entryIds: readonly string[]; - firstSeq: number; - lastSeq: number; - throughChangeSeq: number; - requestDigest: AgentOperationDigest; -} - -const MAX_RECEIPT_REF_DEPTH = 12; -const MAX_RECEIPT_REF_VALUES = 2_048; -const MAX_RECEIPT_REF_BYTES = 64 * 1024; -const MAX_RECEIPT_REF_IDS = 512; -const RECEIPT_REF_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; -const RECEIPT_REF_DIGEST = /^sha256:[a-f0-9]{64}$/; -const RECEIPT_REF_FORBIDDEN = - /^(?:accountId|apiKey|args|arguments|authorization|authToken|baseUrl|body|cookie|credentials?|env|environment|headers?|password|prompt|providerConfig|requestBody|responseBody|secret|token|accessToken|url)$/i; -const receiptRefEncoder = new TextEncoder(); -const receiptRefRecord = (v: unknown): v is Record => - !!v && - typeof v === "object" && - !Array.isArray(v) && - Object.getPrototypeOf(v) === Object.prototype; -const receiptRefExact = ( - v: Record, - keys: readonly string[], -) => - Object.keys(v).length === keys.length && - Object.keys(v).every((key) => keys.includes(key)); -const receiptRefValidId = (v: unknown): v is string => - typeof v === "string" && RECEIPT_REF_ID.test(v); -const receiptRefValidDigest = (v: unknown): v is AgentOperationDigest => - typeof v === "string" && RECEIPT_REF_DIGEST.test(v); -const receiptRefTime = (v: unknown): v is number => - Number.isSafeInteger(v) && (v as number) >= 0; -function receiptRefIds(value: unknown): value is string[] { - return ( - Array.isArray(value) && - value.length <= MAX_RECEIPT_REF_IDS && - value.every(receiptRefValidId) && - new Set(value).size === value.length - ); -} -function receiptRefSafeJson(value: unknown): boolean { - let count = 0; - const visit = (v: unknown, depth: number): boolean => { - if (++count > MAX_RECEIPT_REF_VALUES || depth > MAX_RECEIPT_REF_DEPTH) - return false; - if (v === null || typeof v === "string" || typeof v === "boolean") - return true; - if (typeof v === "number") return Number.isFinite(v); - if (Array.isArray(v)) { - if (Object.getPrototypeOf(v) !== Array.prototype) return false; - const descriptors = Object.getOwnPropertyDescriptors(v); - const keys = Reflect.ownKeys(descriptors); - if ( - keys.some((key) => typeof key !== "string") || - keys.length !== v.length + 1 || - !Object.hasOwn(descriptors, "length") - ) - return false; - for (let index = 0; index < v.length; index++) { - const descriptor = descriptors[String(index)]; - if ( - !descriptor || - !("value" in descriptor) || - !descriptor.enumerable || - descriptor.value === undefined || - !visit(descriptor.value, depth + 1) - ) - return false; - } - return true; - } - if (!receiptRefRecord(v)) return false; - const descriptors = Object.getOwnPropertyDescriptors(v); - const keys = Reflect.ownKeys(descriptors); - if (keys.some((key) => typeof key !== "string")) return false; - return keys.every((key) => { - const descriptor = descriptors[key as string]; - return ( - !!descriptor && - "value" in descriptor && - descriptor.enumerable && - !RECEIPT_REF_FORBIDDEN.test(key as string) && - descriptor.value !== undefined && - visit(descriptor.value, depth + 1) - ); - }); - }; - if (!visit(value, 0)) return false; - try { - // Reject Proxy objects whose traps could change values between inspection - // and decoder reconstruction. - const snapshot = structuredClone(value); - return ( - receiptRefEncoder.encode(JSON.stringify(snapshot)).byteLength <= - MAX_RECEIPT_REF_BYTES - ); - } catch { - return false; - } -} -export function decodeAgentTranscriptReceiptRefV1( - value: unknown, -): AgentTranscriptReceiptRefV1 | undefined { - if ( - !receiptRefSafeJson(value) || - !receiptRefRecord(value) || - !receiptRefExact(value, [ - "appendId", - "entryIds", - "firstSeq", - "lastSeq", - "throughChangeSeq", - "requestDigest", - ]) || - !receiptRefValidId(value.appendId) || - !receiptRefIds(value.entryIds) || - value.entryIds.length === 0 || - !receiptRefTime(value.firstSeq) || - value.firstSeq < 1 || - !receiptRefTime(value.lastSeq) || - value.lastSeq < value.firstSeq || - value.entryIds.length !== value.lastSeq - value.firstSeq + 1 || - !receiptRefTime(value.throughChangeSeq) || - value.throughChangeSeq < 1 || - !receiptRefValidDigest(value.requestDigest) - ) - return undefined; - return Object.freeze({ - appendId: value.appendId, - entryIds: Object.freeze([...value.entryIds]), - firstSeq: value.firstSeq, - lastSeq: value.lastSeq, - throughChangeSeq: value.throughChangeSeq, - requestDigest: value.requestDigest, - }); -} +import { + decodeAgentTranscriptReceiptRefV1, + type AgentTranscriptAnchorV1, + type AgentTranscriptReceiptRefV1, +} from "@tellahq/opensession-protocol/agent-operation"; import type { TranscriptEntry } from "../types"; import type { AppendResult, diff --git a/packages/core/opensession-server/src/server/transcript-store.ts b/packages/core/opensession-server/src/server/transcript-store.ts index 7822f4f26e..934ffcfb90 100644 --- a/packages/core/opensession-server/src/server/transcript-store.ts +++ b/packages/core/opensession-server/src/server/transcript-store.ts @@ -61,6 +61,11 @@ import type { TranscriptEntry } from "./types"; import { sanitizeTranscriptMediaEntry } from "./transcript-media"; import { v2SnapshotEntryWeight } from "./transcript-wire"; import { classifyEntry, dropContextInjections } from "@tellahq/opensession-protocol/notices"; +import { + decodeAgentTranscriptReceiptRefV1, + type AgentTranscriptAnchorV1, + type AgentTranscriptReceiptRefV1, +} from "@tellahq/opensession-protocol/agent-operation"; import type { TranscriptIndexEntry, TranscriptIndexRole, @@ -68,9 +73,6 @@ import type { import { assertTranscriptActorRequest, assertTranscriptActorResponse, - decodeAgentTranscriptReceiptRefV1, - type AgentTranscriptAnchorV1, - type AgentTranscriptReceiptRefV1, type TranscriptActorRequest, type TranscriptMutationResult, type TranscriptWake, diff --git a/packages/core/protocol/package.json b/packages/core/protocol/package.json index 330d5b5c78..fa3b0c6bf8 100644 --- a/packages/core/protocol/package.json +++ b/packages/core/protocol/package.json @@ -17,7 +17,11 @@ "./tool-presentation": "./src/tool-presentation.ts", "./identity": "./src/identity.ts", "./workspace-group": "./src/workspace-group.ts", - "./executor": "./src/executor.ts" + "./executor": "./src/executor.ts", + "./agent-host": "./src/agent-host.ts", + "./agent-host-fence": "./src/agent-host-fence.ts", + "./agent-host-supervision": "./src/agent-host-supervision.ts", + "./agent-operation": "./src/agent-operation.ts" }, "repository": { "type": "git", diff --git a/packages/core/protocol/src/agent-host-fence.ts b/packages/core/protocol/src/agent-host-fence.ts new file mode 100644 index 0000000000..30ea60c9f6 --- /dev/null +++ b/packages/core/protocol/src/agent-host-fence.ts @@ -0,0 +1,28 @@ +import { decodeExecutorId } from "./executor"; + +export interface AgentTurnFence { + readonly sessionId: string; + readonly runId: string; + readonly turnId: string; + readonly generation: number; +} + +const plainExact = (value: unknown, keys: readonly string[]): value is Record => { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) return false; + const descriptors = Object.getOwnPropertyDescriptors(value); + const ownKeys = Reflect.ownKeys(descriptors); + return ownKeys.length === keys.length && ownKeys.every((key) => + typeof key === "string" && keys.includes(key) && "value" in descriptors[key]! && descriptors[key]!.enumerable, + ); +}; + +export function isAgentTurnFence(value: unknown): value is AgentTurnFence { + return plainExact(value, ["sessionId", "runId", "turnId", "generation"]) && + !!decodeExecutorId(value.sessionId) && !!decodeExecutorId(value.runId) && + !!decodeExecutorId(value.turnId) && Number.isSafeInteger(value.generation) && (value.generation as number) >= 0; +} + +export function decodeAgentTurnFence(value: unknown): Readonly | undefined { + if (!isAgentTurnFence(value)) return undefined; + return Object.freeze({ sessionId: value.sessionId, runId: value.runId, turnId: value.turnId, generation: value.generation }); +} diff --git a/packages/core/protocol/src/agent-host-supervision.ts b/packages/core/protocol/src/agent-host-supervision.ts new file mode 100644 index 0000000000..eb92c97d5f --- /dev/null +++ b/packages/core/protocol/src/agent-host-supervision.ts @@ -0,0 +1,574 @@ +import { + AGENT_HOST_SUPERVISION_AUDIENCE, + AGENT_HOST_SUPERVISION_PURPOSE, + AGENT_HOST_SUPERVISION_VERSION, + MAX_AGENT_HOST_SUPERVISION_CLOCK_SKEW_MS, + decodeAgentHostSupervisionAuthorityV2, + serializeAgentHostSupervisionAuthorityV2, + type AgentHostSupervisionAuthorityV2, + type AgentTurnFence, +} from "./agent-host"; + +export const AGENT_HOST_SUPERVISION_ENVELOPE_VERSION = 1 as const; +export const AGENT_HOST_SUPERVISION_KEYRING_VERSION = 1 as const; +export const AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM = "Ed25519" as const; +export const AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN = + "opensession.agent-host.supervision.v2" as const; +export const AGENT_HOST_SUPERVISION_SIGNATURE_RUNTIME = + "opensession-agent-host-runtime.v1" as const; + +const MAX_AUTHORITY_BYTES = 4 * 1024; +const MAX_KEYRING_KEYS = 32; +const ED25519_SIGNATURE_BYTES = 64; +const ED25519_SPKI_BYTES = 44; +const KEY_ID_RE = /^[A-Za-z0-9_-]{16,128}$/; +const BASE64URL_RE = /^(?:[A-Za-z0-9_-]{2,})$/; +const textEncoder = new TextEncoder(); +const strictTextDecoder = new TextDecoder("utf-8", { fatal: true }); +const SPKI_PREFIX = Uint8Array.from([ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, +]); +const SIGNING_PREFIX = textEncoder.encode( + `OpenSession-Signed-Message\0domain=${AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN}\0runtime=${AGENT_HOST_SUPERVISION_SIGNATURE_RUNTIME}\0version=${AGENT_HOST_SUPERVISION_VERSION}\0algorithm=${AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM}\0`, +); + +const record = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); +const exact = (value: Record, keys: readonly string[]) => + Object.keys(value).length === keys.length && + Object.keys(value).every((key) => keys.includes(key)); +const safeTime = (value: unknown): value is number => + Number.isSafeInteger(value) && (value as number) >= 0; +const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => + left.byteLength === right.byteLength && + left.every((byte, index) => byte === right[index]); +function ownedArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +function encodeBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +function decodeCanonicalBase64Url( + value: unknown, + exactBytes: number | undefined, + maxBytes: number, +): Uint8Array | undefined { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > Math.ceil((maxBytes * 4) / 3) || + value.length % 4 === 1 || + value.includes("=") || + !BASE64URL_RE.test(value) + ) + return undefined; + try { + const standard = value.replaceAll("-", "+").replaceAll("_", "/"); + const binary = atob( + standard.padEnd(Math.ceil(standard.length / 4) * 4, "="), + ); + const bytes = Uint8Array.from(binary, (character) => + character.charCodeAt(0), + ); + if ( + bytes.byteLength > maxBytes || + (exactBytes !== undefined && bytes.byteLength !== exactBytes) || + encodeBase64Url(bytes) !== value + ) + return undefined; + return bytes; + } catch { + return undefined; + } +} + +export interface SignedAgentHostSupervisionEnvelopeV1 { + readonly version: typeof AGENT_HOST_SUPERVISION_ENVELOPE_VERSION; + readonly algorithm: typeof AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM; + readonly domain: typeof AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN; + /** Exact canonical authority bytes, encoded as unpadded base64url. */ + readonly authorityBytes: string; + /** A 64-byte Ed25519 signature, encoded as unpadded base64url. */ + readonly signature: string; +} + +const ENVELOPE_KEYS = [ + "version", + "algorithm", + "domain", + "authorityBytes", + "signature", +] as const; + +export function decodeSignedAgentHostSupervisionEnvelopeV1( + value: unknown, +): SignedAgentHostSupervisionEnvelopeV1 | undefined { + if (!record(value) || !exact(value, ENVELOPE_KEYS)) return undefined; + if ( + value.version !== AGENT_HOST_SUPERVISION_ENVELOPE_VERSION || + value.algorithm !== AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM || + value.domain !== AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN || + !decodeCanonicalBase64Url( + value.authorityBytes, + undefined, + MAX_AUTHORITY_BYTES, + ) || + !decodeCanonicalBase64Url( + value.signature, + ED25519_SIGNATURE_BYTES, + ED25519_SIGNATURE_BYTES, + ) + ) + return undefined; + return Object.freeze({ + version: AGENT_HOST_SUPERVISION_ENVELOPE_VERSION, + algorithm: AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + domain: AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN, + authorityBytes: value.authorityBytes as string, + signature: value.signature as string, + }); +} + +export type AgentHostSupervisionPublicKeyStatus = "active" | "retiring"; +export interface AgentHostSupervisionPublicKeyV1 { + readonly keyId: string; + readonly status: AgentHostSupervisionPublicKeyStatus; + readonly publicKeySpki: string; + /** Inclusive signing-time boundary. */ + readonly notBeforeMs: number; + /** Exclusive signing-time boundary. */ + readonly notAfterMs: number; + /** Null for active keys. Retiring keys remain verifiable until this instant. */ + readonly retiredAtMs: number | null; +} +export interface AgentHostSupervisionPublicKeyringV1 { + readonly version: typeof AGENT_HOST_SUPERVISION_KEYRING_VERSION; + readonly algorithm: typeof AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM; + readonly domain: typeof AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN; + readonly keys: readonly AgentHostSupervisionPublicKeyV1[]; +} + +const KEYRING_KEYS = ["version", "algorithm", "domain", "keys"] as const; +const PUBLIC_KEY_KEYS = [ + "keyId", + "status", + "publicKeySpki", + "notBeforeMs", + "notAfterMs", + "retiredAtMs", +] as const; + +function hasEd25519SpkiPrefix(bytes: Uint8Array): boolean { + return ( + bytes.byteLength === ED25519_SPKI_BYTES && + SPKI_PREFIX.every((byte, index) => bytes[index] === byte) + ); +} + +export function decodeAgentHostSupervisionPublicKeyringV1( + value: unknown, +): AgentHostSupervisionPublicKeyringV1 | undefined { + if ( + !record(value) || + !exact(value, KEYRING_KEYS) || + value.version !== AGENT_HOST_SUPERVISION_KEYRING_VERSION || + value.algorithm !== AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM || + value.domain !== AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN || + !Array.isArray(value.keys) || + value.keys.length === 0 || + value.keys.length > MAX_KEYRING_KEYS + ) + return undefined; + const seen = new Set(); + const keys: AgentHostSupervisionPublicKeyV1[] = []; + for (const candidate of value.keys) { + if (!record(candidate) || !exact(candidate, PUBLIC_KEY_KEYS)) + return undefined; + const spki = decodeCanonicalBase64Url( + candidate.publicKeySpki, + ED25519_SPKI_BYTES, + ED25519_SPKI_BYTES, + ); + if ( + typeof candidate.keyId !== "string" || + !KEY_ID_RE.test(candidate.keyId) || + seen.has(candidate.keyId) || + (candidate.status !== "active" && candidate.status !== "retiring") || + !spki || + !hasEd25519SpkiPrefix(spki) || + !safeTime(candidate.notBeforeMs) || + !safeTime(candidate.notAfterMs) || + candidate.notAfterMs <= candidate.notBeforeMs || + (candidate.status === "active" + ? candidate.retiredAtMs !== null + : !safeTime(candidate.retiredAtMs) || + candidate.retiredAtMs < candidate.notAfterMs) + ) + return undefined; + seen.add(candidate.keyId); + keys.push( + Object.freeze({ + keyId: candidate.keyId, + status: candidate.status, + publicKeySpki: candidate.publicKeySpki, + notBeforeMs: candidate.notBeforeMs, + notAfterMs: candidate.notAfterMs, + retiredAtMs: candidate.retiredAtMs, + }) as AgentHostSupervisionPublicKeyV1, + ); + } + return Object.freeze({ + version: AGENT_HOST_SUPERVISION_KEYRING_VERSION, + algorithm: AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + domain: AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN, + keys: Object.freeze(keys), + }); +} + +export interface ExpectedAgentHostSupervisionBindingsV2 { + readonly fence: Readonly; + readonly planHash: string; + readonly hostId: string; + readonly hostGeneration: number; + readonly hostIncarnation: string; + readonly supervisorEpoch: number; + readonly kernelServiceEpoch: string; + readonly hostChallenge: string; + readonly nonce: string; + readonly audience: typeof AGENT_HOST_SUPERVISION_AUDIENCE; + readonly purpose: typeof AGENT_HOST_SUPERVISION_PURPOSE; +} + +const EXPECTED_KEYS = [ + "fence", + "planHash", + "hostId", + "hostGeneration", + "hostIncarnation", + "supervisorEpoch", + "kernelServiceEpoch", + "hostChallenge", + "nonce", + "audience", + "purpose", +] as const; + +function expectedMatches( + authority: AgentHostSupervisionAuthorityV2, + value: unknown, +): value is ExpectedAgentHostSupervisionBindingsV2 { + if (!record(value) || !exact(value, EXPECTED_KEYS) || !record(value.fence)) + return false; + const fence = value.fence; + return ( + exact(fence, ["sessionId", "runId", "turnId", "generation"]) && + authority.fence.sessionId === fence.sessionId && + authority.fence.runId === fence.runId && + authority.fence.turnId === fence.turnId && + authority.fence.generation === fence.generation && + authority.planHash === value.planHash && + authority.hostId === value.hostId && + authority.hostGeneration === value.hostGeneration && + authority.hostIncarnation === value.hostIncarnation && + authority.supervisorEpoch === value.supervisorEpoch && + authority.kernelServiceEpoch === value.kernelServiceEpoch && + authority.hostChallenge === value.hostChallenge && + authority.nonce === value.nonce && + authority.audience === value.audience && + authority.purpose === value.purpose + ); +} + +export function decodeCanonicalAgentHostSupervisionAuthorityBytesV2( + bytes: Uint8Array, + nowMs?: number, +): AgentHostSupervisionAuthorityV2 | undefined { + if (!(bytes instanceof Uint8Array) || bytes.byteLength > MAX_AUTHORITY_BYTES) + return undefined; + try { + const parsed: unknown = JSON.parse(strictTextDecoder.decode(bytes)); + const authority = decodeAgentHostSupervisionAuthorityV2(parsed, nowMs); + if (!authority) return undefined; + const canonical = serializeAgentHostSupervisionAuthorityV2(authority); + return bytesEqual(bytes, canonical) ? authority : undefined; + } catch { + return undefined; + } +} + +/** Builds the immutable, length-delimited bytes signed and verified by both + * services. This never signs an authority object or envelope JSON. */ +export function agentHostSupervisionSigningBytesV1( + canonicalAuthorityBytes: Uint8Array, +): Uint8Array { + if ( + !(canonicalAuthorityBytes instanceof Uint8Array) || + canonicalAuthorityBytes.byteLength === 0 || + canonicalAuthorityBytes.byteLength > MAX_AUTHORITY_BYTES + ) + throw new Error("Invalid canonical Agent Host authority bytes"); + const output = new Uint8Array( + SIGNING_PREFIX.byteLength + 4 + canonicalAuthorityBytes.byteLength, + ); + output.set(SIGNING_PREFIX); + new DataView(output.buffer).setUint32( + SIGNING_PREFIX.byteLength, + canonicalAuthorityBytes.byteLength, + false, + ); + output.set(canonicalAuthorityBytes, SIGNING_PREFIX.byteLength + 4); + return output; +} + +/** Public-key-only Host verifier. Every authority binding is mandatory. */ +export async function verifySignedAgentHostSupervisionEnvelopeV1( + envelopeValue: unknown, + keyringValue: unknown, + expectedBindings: ExpectedAgentHostSupervisionBindingsV2, + nowMs: number, +): Promise { + if (!safeTime(nowMs)) return undefined; + const envelope = decodeSignedAgentHostSupervisionEnvelopeV1(envelopeValue); + const keyring = decodeAgentHostSupervisionPublicKeyringV1(keyringValue); + if (!envelope || !keyring) return undefined; + const authorityBytes = decodeCanonicalBase64Url( + envelope.authorityBytes, + undefined, + MAX_AUTHORITY_BYTES, + )!; + const authority = decodeCanonicalAgentHostSupervisionAuthorityBytesV2( + authorityBytes, + nowMs, + ); + if (!authority || !KEY_ID_RE.test(authority.keyId)) return undefined; + if (!expectedMatches(authority, expectedBindings)) return undefined; + const key = keyring.keys.find( + (candidate) => candidate.keyId === authority.keyId, + ); + if ( + !key || + authority.issuedAtMs < key.notBeforeMs || + authority.issuedAtMs >= key.notAfterMs || + (key.status === "retiring" && + key.retiredAtMs! < + authority.expiresAtMs + MAX_AGENT_HOST_SUPERVISION_CLOCK_SKEW_MS) + ) + return undefined; + try { + const publicKey = await crypto.subtle.importKey( + "spki", + ownedArrayBuffer( + decodeCanonicalBase64Url( + key.publicKeySpki, + ED25519_SPKI_BYTES, + ED25519_SPKI_BYTES, + )!, + ), + { name: AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM }, + false, + ["verify"], + ); + const signature = decodeCanonicalBase64Url( + envelope.signature, + ED25519_SIGNATURE_BYTES, + ED25519_SIGNATURE_BYTES, + )!; + const valid = await crypto.subtle.verify( + AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + publicKey, + ownedArrayBuffer(signature), + ownedArrayBuffer(agentHostSupervisionSigningBytesV1(authorityBytes)), + ); + return valid ? authority : undefined; + } catch { + return undefined; + } +} + +export const AGENT_HOST_SUPERVISION_KEYRING_VERSION_V2 = 2 as const; +export interface AgentHostSupervisionPublicKeyV2 { + readonly keyId: string; + readonly status: "active" | "retiring"; + readonly publicKeySpki: string; + readonly signingNotBeforeMs: number; + readonly signingNotAfterMs: number; + readonly verifyUntilMs: number; +} +export interface AgentHostSupervisionPublicKeyringV2 { + readonly version: typeof AGENT_HOST_SUPERVISION_KEYRING_VERSION_V2; + readonly algorithm: typeof AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM; + readonly domain: typeof AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN; + readonly keys: readonly AgentHostSupervisionPublicKeyV2[]; +} +const PUBLIC_KEY_V2_KEYS = [ + "keyId", + "status", + "publicKeySpki", + "signingNotBeforeMs", + "signingNotAfterMs", + "verifyUntilMs", +] as const; +export function decodeAgentHostSupervisionPublicKeyringV2( + value: unknown, +): AgentHostSupervisionPublicKeyringV2 | undefined { + if ( + !record(value) || + !exact(value, KEYRING_KEYS) || + value.version !== 2 || + value.algorithm !== AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM || + value.domain !== AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN || + !Array.isArray(value.keys) || + value.keys.length === 0 || + value.keys.length > MAX_KEYRING_KEYS + ) + return undefined; + const seen = new Set(); + let active = 0; + const keys: AgentHostSupervisionPublicKeyV2[] = []; + for (const candidate of value.keys) { + if (!record(candidate) || !exact(candidate, PUBLIC_KEY_V2_KEYS)) + return undefined; + const spki = decodeCanonicalBase64Url( + candidate.publicKeySpki, + ED25519_SPKI_BYTES, + ED25519_SPKI_BYTES, + ); + if ( + typeof candidate.keyId !== "string" || + !KEY_ID_RE.test(candidate.keyId) || + seen.has(candidate.keyId) || + (candidate.status !== "active" && candidate.status !== "retiring") || + !spki || + !hasEd25519SpkiPrefix(spki) || + !safeTime(candidate.signingNotBeforeMs) || + !safeTime(candidate.signingNotAfterMs) || + candidate.signingNotAfterMs <= candidate.signingNotBeforeMs || + !safeTime(candidate.verifyUntilMs) || + candidate.verifyUntilMs < candidate.signingNotAfterMs + ) + return undefined; + if (candidate.status === "active") active += 1; + seen.add(candidate.keyId); + keys.push( + Object.freeze({ + keyId: candidate.keyId, + status: candidate.status, + publicKeySpki: candidate.publicKeySpki as string, + signingNotBeforeMs: candidate.signingNotBeforeMs as number, + signingNotAfterMs: candidate.signingNotAfterMs as number, + verifyUntilMs: candidate.verifyUntilMs as number, + }), + ); + } + if (active !== 1) return undefined; + return Object.freeze({ + version: 2, + algorithm: AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + domain: AGENT_HOST_SUPERVISION_SIGNATURE_DOMAIN, + keys: Object.freeze(keys), + }); +} +export interface ExpectedAgentHostSupervisionBindingsV3 extends ExpectedAgentHostSupervisionBindingsV2 { + readonly keyId: string; + readonly issuedAtMs: number; + readonly expiresAtMs: number; +} +/** Authorizing future-path verifier. V1 remains legacy and non-authorizing. */ +export async function verifySignedAgentHostSupervisionEnvelopeV2( + envelopeValue: unknown, + keyringValue: unknown, + expected: ExpectedAgentHostSupervisionBindingsV3, + nowMs: number, +): Promise { + if ( + !safeTime(nowMs) || + !record(expected) || + !exact(expected, [...EXPECTED_KEYS, "keyId", "issuedAtMs", "expiresAtMs"]) + ) + return undefined; + const envelope = decodeSignedAgentHostSupervisionEnvelopeV1(envelopeValue); + const keyring = decodeAgentHostSupervisionPublicKeyringV2(keyringValue); + if (!envelope || !keyring) return undefined; + const authorityBytes = decodeCanonicalBase64Url( + envelope.authorityBytes, + undefined, + MAX_AUTHORITY_BYTES, + )!; + const authority = decodeCanonicalAgentHostSupervisionAuthorityBytesV2( + authorityBytes, + nowMs, + ); + const legacyExpected = { + fence: expected.fence, + planHash: expected.planHash, + hostId: expected.hostId, + hostGeneration: expected.hostGeneration, + hostIncarnation: expected.hostIncarnation, + supervisorEpoch: expected.supervisorEpoch, + kernelServiceEpoch: expected.kernelServiceEpoch, + hostChallenge: expected.hostChallenge, + nonce: expected.nonce, + audience: expected.audience, + purpose: expected.purpose, + }; + if ( + !authority || + !expectedMatches(authority, legacyExpected) || + authority.keyId !== expected.keyId || + authority.issuedAtMs !== expected.issuedAtMs || + authority.expiresAtMs !== expected.expiresAtMs + ) + return undefined; + const key = keyring.keys.find( + (candidate) => candidate.keyId === authority.keyId, + ); + if ( + !key || + nowMs >= key.verifyUntilMs || + authority.expiresAtMs > + key.verifyUntilMs - MAX_AGENT_HOST_SUPERVISION_CLOCK_SKEW_MS || + authority.issuedAtMs < key.signingNotBeforeMs || + authority.issuedAtMs >= key.signingNotAfterMs + ) + return undefined; + try { + const publicKey = await crypto.subtle.importKey( + "spki", + ownedArrayBuffer( + decodeCanonicalBase64Url( + key.publicKeySpki, + ED25519_SPKI_BYTES, + ED25519_SPKI_BYTES, + )!, + ), + { name: AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM }, + false, + ["verify"], + ); + return (await crypto.subtle.verify( + AGENT_HOST_SUPERVISION_SIGNATURE_ALGORITHM, + publicKey, + ownedArrayBuffer( + decodeCanonicalBase64Url( + envelope.signature, + ED25519_SIGNATURE_BYTES, + ED25519_SIGNATURE_BYTES, + )!, + ), + ownedArrayBuffer(agentHostSupervisionSigningBytesV1(authorityBytes)), + )) + ? authority + : undefined; + } catch { + return undefined; + } +} diff --git a/packages/core/protocol/src/agent-host.test.ts b/packages/core/protocol/src/agent-host.test.ts new file mode 100644 index 0000000000..bf158cc009 --- /dev/null +++ b/packages/core/protocol/src/agent-host.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { + AGENT_HOST_PROTOCOL_VERSION, + MAX_AGENT_HOST_STREAM_BYTES, + MAX_AGENT_HOST_STREAM_CHUNK_BYTES, + decodeAgentHostAttachResumeCursorV4, + decodeAgentHostHello, + decodeAgentHostOperationCancel, + decodeAgentHostOperationCancelReceipt, + decodeAgentHostOperationQuery, + decodeAgentHostOperationQueryReceipt, + decodeAgentHostOperationReceipt, + decodeAgentHostOperationRequest, + decodeAgentHostOperationRequestExact, + decodeAgentHostOperationStream, + decodeAgentHostOperationStreamAck, + decodeAgentHostStartTurn, + decodeAgentHostConsumptionAck, + decodeAgentHostTurnTerminal, + decodeAgentHostTurnTerminalAck, + hashAgentTurnResultV1, + hashAgentTurnTerminalReceiptsV1, + decodeAgentTurnSpec, + hashAgentTurnSpecV2, + type AgentTurnSpec, +} from "./agent-host"; +import { hashAgentOperationDescriptorV1, type AgentOperationReceiptV1 } from "./agent-operation"; + +const now = 1_000; +const d = (char: string) => `sha256:${char.repeat(64)}` as const; +const fence = { sessionId: "session-1", runId: "run-1", turnId: "turn-1", generation: 1 }; +const descriptor = { + version: 1 as const, + kind: "model" as const, + stepId: "step-1", + transcript: { throughChangeSeq: 2, entryIds: ["entry-1"], digest: d("a") }, + modelPolicyHash: d("b"), + adapterRequestVersion: "model.v1", +}; +const receipt: AgentOperationReceiptV1 = { + version: 1, operationId: "operation-1", kind: "model", fence, + planHash: d("c"), authorityHash: d("d"), descriptorDigest: d("e"), payloadDigest: d("f"), + actorIdentity: { supervisorEpoch: 1, hostId: "host-1", hostGeneration: 1, hostIncarnation: "incarnation-1", transcriptAnchor: descriptor.transcript }, + state: "prepared", acceptedAtMs: 1, providerRef: { adapterId: "adapter-1", adapterVersion: "1" }, +}; +const common = { version: 5 as const, requestId: "request-1", fence }; + +async function makeSpec(): Promise { + return { + fence, + initialOperation: { operationId: "operation-1", descriptor, descriptorDigest: await hashAgentOperationDescriptorV1(descriptor), deadlineMs: now + 60_000 }, + transcript: { afterChangeSeq: 2, maxAppendBytes: 64_000, requireAck: true }, + limits: { turnDeadlineMs: now + 120_000, maxInFlightOperations: 8, maxBufferedStreamBytes: 512 * 1024, maxBufferedStreamChunks: 32 }, + }; +} + +describe("Agent Host protocol v5", () => { + test("hard cuts v4 and exact hello keys", () => { + expect(AGENT_HOST_PROTOCOL_VERSION).toBe(5); + expect(decodeAgentHostHello({ t: "hello", version: 5, requestId: "request-1" })).toBeDefined(); + expect(decodeAgentHostHello({ t: "hello", version: 4, requestId: "request-1" })).toBeUndefined(); + expect(decodeAgentHostHello({ t: "hello", version: 5, requestId: "request-1", extra: true })).toBeUndefined(); + }); + + test("decodes only the frozen descriptor-only turn spec and hashes domain v2", async () => { + const spec = await makeSpec(); + const decoded = decodeAgentTurnSpec(spec, now)!; + expect(decoded).toEqual(spec); + expect(Object.isFrozen(decoded)).toBe(true); + expect(Object.isFrozen(decoded.initialOperation.descriptor)).toBe(true); + const hash = await hashAgentTurnSpecV2(spec, now); + expect(hash).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(await hashAgentTurnSpecV2({ ...spec, transcript: { ...spec.transcript, afterChangeSeq: 3 } }, now)).not.toBe(hash); + for (const key of ["input", "prompt", "images", "mode", "modelPolicy", "enginePolicy", "mcpPolicy", "runPolicy", "identityPolicy", "environmentPolicy", "workspacePolicy", "executorPolicy", "accessGrant"]) + expect(decodeAgentTurnSpec({ ...spec, [key]: "forbidden" }, now)).toBeUndefined(); + expect(decodeAgentTurnSpec({ ...spec, limits: { ...spec.limits, maxInFlightOperations: 9 } }, now)).toBeUndefined(); + expect(decodeAgentTurnSpec({ ...spec, initialOperation: { ...spec.initialOperation, deadlineMs: now + 5 * 60_000 + 1 } }, now)).toBeUndefined(); + expect(decodeAgentHostStartTurn({ t: "start_turn", version: 5, requestId: "request-1", planHash: d("1"), spec }, now)).toBeDefined(); + }); + + test("strictly decodes sorted immutable attach resume cursors", () => { + const cursor = { lastHostSeq: 7, operations: [{ operationId: "operation-1", throughStreamSeq: 2 }, { operationId: "operation-2", throughStreamSeq: 0 }] }; + const decoded = decodeAgentHostAttachResumeCursorV4(cursor)!; + expect(decoded).toEqual(cursor); + expect(Object.isFrozen(decoded.operations)).toBe(true); + expect(decodeAgentHostAttachResumeCursorV4(null)).toBeNull(); + expect(decodeAgentHostAttachResumeCursorV4({ ...cursor, extra: true })).toBeUndefined(); + expect(decodeAgentHostAttachResumeCursorV4({ ...cursor, operations: [...cursor.operations].reverse() })).toBeUndefined(); + expect(decodeAgentHostAttachResumeCursorV4({ ...cursor, operations: Array(9).fill(cursor.operations[0]) })).toBeUndefined(); + }); + + test("decodes exact operation intents and verifies request digests", async () => { + const descriptorDigest = await hashAgentOperationDescriptorV1(descriptor); + const request = { t: "operation_request", ...common, hostSeq: 1, operationId: "operation-1", descriptor, descriptorDigest, deadlineMs: now + 60_000 }; + expect(await decodeAgentHostOperationRequest(request, now)).toBeDefined(); + expect(await decodeAgentHostOperationRequestExact(request, now)).toBeDefined(); + expect(await decodeAgentHostOperationRequestExact({ ...request, descriptorDigest: d("0") }, now)).toBeUndefined(); + const query = { t: "operation_query", ...common, hostSeq: 2, operationId: "operation-1", kind: "model", descriptorDigest, payloadDigest: d("2"), afterStreamSeq: 0 }; + expect(decodeAgentHostOperationQuery(query)).toBeDefined(); + expect(decodeAgentHostOperationQuery({ ...query, prompt: "forbidden" })).toBeUndefined(); + const cancel = { t: "operation_cancel", ...common, hostSeq: 3, operationId: "operation-1", cancelId: "cancel-1", reason: "reconnect_deadline" }; + expect(decodeAgentHostOperationCancel(cancel)).toBeDefined(); + expect(decodeAgentHostOperationCancel({ ...cancel, reason: "timeout" })).toBeUndefined(); + }); + + test("decodes bound receipt wrappers and rejects crossover", () => { + const wrapper = { t: "operation_receipt", ...common, ackHostSeq: 1, operationId: "operation-1", receipt }; + expect(decodeAgentHostOperationReceipt(wrapper)).toBeDefined(); + expect(decodeAgentHostOperationReceipt({ ...wrapper, operationId: "operation-2" })).toBeUndefined(); + expect(decodeAgentHostOperationQueryReceipt({ t: "operation_query_receipt", ...common, ackHostSeq: 2, operationId: "operation-1", fromStreamSeq: 1, receipt })).toBeDefined(); + expect(decodeAgentHostOperationCancelReceipt({ t: "operation_cancel_receipt", ...common, ackHostSeq: 3, operationId: "operation-1", cancelId: "cancel-1", disposition: "indeterminate", receipt })).toBeDefined(); + }); + + test("enforces canonical bounded opaque stream chunks and exact ACK credits", () => { + const stream = { t: "operation_stream", ...common, operationId: "operation-1", streamSeq: 1, encoding: "base64url+opensession-operation-v1", bytes: "aGVsbG8" }; + expect(decodeAgentHostOperationStream(stream)).toBeDefined(); + for (const bytes of ["aGVsbG8=", "aGVsbG8+", "", "A".repeat(Math.ceil((MAX_AGENT_HOST_STREAM_CHUNK_BYTES + 1) * 4 / 3))]) + expect(decodeAgentHostOperationStream({ ...stream, bytes })).toBeUndefined(); + const ack = { t: "operation_stream_ack", ...common, hostSeq: 4, operationId: "operation-1", throughStreamSeq: 1, creditBytes: MAX_AGENT_HOST_STREAM_BYTES, creditChunks: 32 }; + expect(decodeAgentHostOperationStreamAck(ack)).toBeDefined(); + expect(decodeAgentHostOperationStreamAck({ ...ack, creditBytes: MAX_AGENT_HOST_STREAM_BYTES + 1 })).toBeUndefined(); + expect(decodeAgentHostOperationStreamAck({ ...ack, creditChunks: 33 })).toBeUndefined(); + }); + + test("decodes exact terminal projection and cumulative acknowledgements", async () => { + const operations = [{ operationId: "operation-1", receiptDigest: d("8"), throughStreamSeq: 2 }]; + const terminal = { + t: "turn_terminal" as const, ...common, hostSeq: 5, + hostGeneration: 7, hostIncarnation: "incarnation-terminal-1", + result: { status: "failed" as const }, resultDigest: await hashAgentTurnResultV1({ status: "failed", error: "redacted from frame" }), + receiptsDigest: await hashAgentTurnTerminalReceiptsV1(operations), finalAckHostSeq: 4, operations, + }; + expect(decodeAgentHostTurnTerminal(terminal)).toEqual(terminal); + expect(JSON.stringify(terminal)).not.toContain("redacted from frame"); + expect(decodeAgentHostTurnTerminal({ ...terminal, prompt: "forbidden" })).toBeUndefined(); + expect(decodeAgentHostConsumptionAck({ t: "consumption_ack", ...common, ackHostSeq: 4, operations: [{ operationId: "operation-1", throughStreamSeq: 2 }] })).toBeDefined(); + expect(decodeAgentHostTurnTerminalAck({ t: "turn_terminal_ack", ...common, ackHostSeq: 5, resultDigest: terminal.resultDigest, receiptsDigest: terminal.receiptsDigest })).toBeDefined(); + expect(decodeAgentHostTurnTerminalAck({ t: "turn_terminal_ack", ...common, ackHostSeq: 5, resultDigest: terminal.resultDigest, receiptsDigest: terminal.receiptsDigest, fallback: true })).toBeUndefined(); + }); + + test("rejects accessors and non-plain objects", async () => { + const spec = await makeSpec(); + const accessor = { ...spec } as Record; + Object.defineProperty(accessor, "fence", { enumerable: true, get: () => fence }); + expect(decodeAgentTurnSpec(accessor, now)).toBeUndefined(); + expect(decodeAgentTurnSpec(Object.assign(Object.create({ inherited: true }), spec), now)).toBeUndefined(); + expect(decodeAgentTurnSpec(new Proxy(spec, {}), now)).toBeUndefined(); + }); +}); diff --git a/packages/core/protocol/src/agent-host.ts b/packages/core/protocol/src/agent-host.ts new file mode 100644 index 0000000000..62c4391a44 --- /dev/null +++ b/packages/core/protocol/src/agent-host.ts @@ -0,0 +1,367 @@ +import { + decodeAgentOperationDescriptorV1, + decodeAgentOperationReceiptV1, + hashAgentOperationDescriptorV1, + hashAgentOperationReceiptV1, + type AgentOperationDescriptorV1, + type AgentOperationDigest, + type AgentOperationKind, + type AgentOperationReceiptV1, +} from "./agent-operation"; +import { decodeExecutorId } from "./executor"; +import { decodeAgentTurnFence, isAgentTurnFence, type AgentTurnFence } from "./agent-host-fence"; +import type { + ExpectedAgentHostSupervisionBindingsV3, + SignedAgentHostSupervisionEnvelopeV1, +} from "./agent-host-supervision"; +export { decodeAgentTurnFence, isAgentTurnFence, type AgentTurnFence } from "./agent-host-fence"; + +export const AGENT_HOST_PROTOCOL_VERSION = 5 as const; +export const AGENT_HOST_SUPERVISION_VERSION = 2 as const; +export const AGENT_HOST_SUPERVISION_AUDIENCE = "opensession-agent-host" as const; +export const AGENT_HOST_SUPERVISION_PURPOSE = "agent-host-supervision" as const; +export const MAX_AGENT_HOST_SUPERVISION_LEASE_MS = 5 * 60_000; +export const MAX_AGENT_HOST_SUPERVISION_CLOCK_SKEW_MS = 30_000; +export const MAX_AGENT_TRANSCRIPT_APPEND_BYTES = 768 * 1024; +export const MAX_AGENT_OPERATION_DURATION_MS = 5 * 60_000; +export const MAX_AGENT_TURN_DURATION_MS = 24 * 60 * 60_000; +export const MAX_AGENT_HOST_IN_FLIGHT_OPERATIONS = 8; +export const MAX_AGENT_HOST_STREAM_CHUNK_BYTES = 48 * 1024; +export const INITIAL_AGENT_HOST_STREAM_CHUNKS = 16; +export const INITIAL_AGENT_HOST_STREAM_BYTES = 256 * 1024; +export const MAX_AGENT_HOST_STREAM_CHUNKS = 32; +export const MAX_AGENT_HOST_STREAM_BYTES = 512 * 1024; +export const MAX_AGENT_HOST_REPLAY_FRAMES = 128; +export const MAX_AGENT_HOST_REPLAY_BYTES = 1024 * 1024; +export const MAX_AGENT_HOST_WRITABLE_BYTES = 512 * 1024; + +const textEncoder = new TextEncoder(); +const SHA256_RE = /^sha256:[a-f0-9]{64}$/; +const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/; +const SUPERVISION_TOKEN_RE = /^[A-Za-z0-9_-]{16,256}$/; +const STREAM_ENCODING = "base64url+opensession-operation-v1" as const; +const record = (value: unknown): value is Record => { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) return false; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + return keys.every((key) => typeof key === "string" && "value" in descriptors[key]! && descriptors[key]!.enumerable); +}; +const exact = (value: Record, keys: readonly string[]) => { + const actual = Object.keys(value); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +}; +const boundedString = (value: unknown, maxBytes: number, allowEmpty = false): value is string => + typeof value === "string" && (allowEmpty || value.length > 0) && textEncoder.encode(value).byteLength <= maxBytes; +const boundedName = (value: unknown, maxBytes = 16 * 1024): value is string => boundedString(value, maxBytes) && !CONTROL_CHARACTER_RE.test(value); +const id = (value: unknown): value is string => typeof value === "string" && !!decodeExecutorId(value); +const digest = (value: unknown): value is AgentOperationDigest => typeof value === "string" && SHA256_RE.test(value); +const uint = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; +const positive = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0; +function deepFreeze(value: T): T { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value as Record)) deepFreeze(child); + } + return value; +} +function immutable(value: T): T | undefined { + try { + const clone = structuredClone(value); + if (JSON.stringify(clone) !== JSON.stringify(value)) return undefined; + return deepFreeze(clone); + } catch { return undefined; } +} + +export interface AgentHostInitialOperationV4 { + readonly operationId: string; + readonly descriptor: AgentOperationDescriptorV1; + readonly descriptorDigest: AgentOperationDigest; + readonly deadlineMs: number; +} +export interface AgentTurnSpec { + readonly fence: Readonly; + readonly initialOperation: Readonly; + readonly transcript: Readonly<{ afterChangeSeq: number; maxAppendBytes: number; requireAck: true }>; + readonly limits: Readonly<{ turnDeadlineMs: number; maxInFlightOperations: number; maxBufferedStreamBytes: number; maxBufferedStreamChunks: number }>; +} +export function decodeAgentTurnSpec(value: unknown, nowMs = Date.now()): AgentTurnSpec | undefined { + if (!record(value) || !exact(value, ["fence", "initialOperation", "transcript", "limits"])) return undefined; + const snapshot = immutable(value); + if (!snapshot || !record(snapshot)) return undefined; + const fence = decodeAgentTurnFence(snapshot.fence), initial = snapshot.initialOperation, transcript = snapshot.transcript, limits = snapshot.limits; + if (!fence || !record(initial) || !exact(initial, ["operationId", "descriptor", "descriptorDigest", "deadlineMs"]) || !id(initial.operationId) || !digest(initial.descriptorDigest) || !positive(initial.deadlineMs) || + !record(transcript) || !exact(transcript, ["afterChangeSeq", "maxAppendBytes", "requireAck"]) || !uint(transcript.afterChangeSeq) || !positive(transcript.maxAppendBytes) || transcript.maxAppendBytes > MAX_AGENT_TRANSCRIPT_APPEND_BYTES || transcript.requireAck !== true || + !record(limits) || !exact(limits, ["turnDeadlineMs", "maxInFlightOperations", "maxBufferedStreamBytes", "maxBufferedStreamChunks"]) || !positive(limits.turnDeadlineMs) || limits.turnDeadlineMs <= nowMs || limits.turnDeadlineMs > nowMs + MAX_AGENT_TURN_DURATION_MS || + initial.deadlineMs <= nowMs || initial.deadlineMs > nowMs + MAX_AGENT_OPERATION_DURATION_MS || initial.deadlineMs > limits.turnDeadlineMs || + !positive(limits.maxInFlightOperations) || limits.maxInFlightOperations > MAX_AGENT_HOST_IN_FLIGHT_OPERATIONS || !positive(limits.maxBufferedStreamBytes) || limits.maxBufferedStreamBytes > MAX_AGENT_HOST_STREAM_BYTES || !positive(limits.maxBufferedStreamChunks) || limits.maxBufferedStreamChunks > MAX_AGENT_HOST_STREAM_CHUNKS) return undefined; + const descriptor = decodeAgentOperationDescriptorV1(initial.descriptor); + if (!descriptor) return undefined; + return deepFreeze({ fence, initialOperation: { operationId: initial.operationId, descriptor, descriptorDigest: initial.descriptorDigest, deadlineMs: initial.deadlineMs }, transcript: { afterChangeSeq: transcript.afterChangeSeq, maxAppendBytes: transcript.maxAppendBytes, requireAck: true }, limits: { turnDeadlineMs: limits.turnDeadlineMs, maxInFlightOperations: limits.maxInFlightOperations, maxBufferedStreamBytes: limits.maxBufferedStreamBytes, maxBufferedStreamChunks: limits.maxBufferedStreamChunks } }); +} +const AGENT_TURN_PLAN_HASH_DOMAIN = "OpenSession-Agent-Turn-Plan-v2\0"; +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (record(value)) return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])); + return value; +} +export async function hashAgentTurnSpecV2(spec: AgentTurnSpec, nowMs = Date.now()): Promise { + const decoded = decodeAgentTurnSpec(spec, nowMs); + if (!decoded) throw new TypeError("Invalid Agent Host turn specification"); + if (await hashAgentOperationDescriptorV1(decoded.initialOperation.descriptor) !== decoded.initialOperation.descriptorDigest) throw new TypeError("Agent Host descriptor digest mismatch"); + const bytes = textEncoder.encode(`${AGENT_TURN_PLAN_HASH_DOMAIN}${JSON.stringify(canonical(decoded))}`); + const result = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + return `sha256:${[...result].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +interface Base { readonly t: string; readonly version: 5; readonly requestId: string } +interface Fenced extends Base { readonly fence: Readonly } +export interface AgentHostAttachResumeCursorV4 { readonly lastHostSeq: number; readonly operations: readonly Readonly<{ operationId: string; throughStreamSeq: number }>[] } +export interface AgentHostChallengeDescriptorV4 { readonly hostId: string; readonly hostGeneration: number; readonly hostIncarnation: string; readonly hostChallenge: string } +export type AgentHostChallengeDescriptorV3 = AgentHostChallengeDescriptorV4; +export interface AgentHostSignedAttachReceiptV4 { readonly expected: ExpectedAgentHostSupervisionBindingsV3; readonly envelope: SignedAgentHostSupervisionEnvelopeV1 } +export type AgentHostSignedAttachReceiptV3 = AgentHostSignedAttachReceiptV4; +export type AgentHostAttachV4 = Fenced & { readonly t: "attach"; readonly planHash: AgentOperationDigest; readonly receipt: AgentHostSignedAttachReceiptV4; readonly resume: AgentHostAttachResumeCursorV4 | null }; +export type AgentHostAttachedV4 = Fenced & { readonly t: "attached"; readonly planHash: AgentOperationDigest; readonly supervisorEpoch: number; readonly mode: "fresh" | "resumed" | "recovery_required"; readonly replayFromHostSeq: number }; +export type AgentHostStartTurnV4 = Base & { readonly t: "start_turn"; readonly planHash: AgentOperationDigest; readonly spec: AgentTurnSpec }; +type HostAsync = Fenced & { readonly hostSeq: number; readonly operationId: string }; +type GatewayResult = Fenced & { readonly ackHostSeq: number; readonly operationId: string }; +export type AgentHostOperationRequestV4 = HostAsync & { readonly t: "operation_request"; readonly descriptor: AgentOperationDescriptorV1; readonly descriptorDigest: AgentOperationDigest; readonly deadlineMs: number }; +export type AgentHostOperationQueryV4 = HostAsync & { readonly t: "operation_query"; readonly kind: AgentOperationKind; readonly descriptorDigest: AgentOperationDigest; readonly payloadDigest: AgentOperationDigest; readonly afterStreamSeq: number }; +export type AgentHostOperationCancelV4 = HostAsync & { readonly t: "operation_cancel"; readonly cancelId: string; readonly reason: "user" | "turn_deadline" | "shutdown" | "reconnect_deadline" }; +export type AgentHostOperationReceiptV4 = GatewayResult & { readonly t: "operation_receipt"; readonly receipt: AgentOperationReceiptV1 }; +export type AgentHostOperationQueryReceiptV4 = GatewayResult & { readonly t: "operation_query_receipt"; readonly fromStreamSeq: number; readonly receipt: AgentOperationReceiptV1 }; +export type AgentHostOperationCancelReceiptV4 = GatewayResult & { readonly t: "operation_cancel_receipt"; readonly cancelId: string; readonly disposition: "not_started" | "cancelled" | "too_late" | "indeterminate"; readonly receipt: AgentOperationReceiptV1 }; +export type AgentHostOperationStreamV4 = Fenced & { readonly t: "operation_stream"; readonly operationId: string; readonly streamSeq: number; readonly encoding: typeof STREAM_ENCODING; readonly bytes: string }; +export type AgentHostOperationStreamAckV4 = HostAsync & { readonly t: "operation_stream_ack"; readonly throughStreamSeq: number; readonly creditBytes: number; readonly creditChunks: number }; +export interface AgentHostTerminalOperationV5 { readonly operationId: string; readonly receiptDigest: AgentOperationDigest; readonly throughStreamSeq: number } +export type AgentHostConsumptionAckV5 = Fenced & { readonly t: "consumption_ack"; readonly ackHostSeq: number; readonly operations: readonly Readonly<{ operationId: string; throughStreamSeq: number }>[] }; +export type AgentHostTurnTerminalV5 = Fenced & { readonly t: "turn_terminal"; readonly hostSeq: number; readonly hostGeneration: number; readonly hostIncarnation: string; readonly result: Readonly<{ status: "completed" | "cancelled" | "failed" }>; readonly resultDigest: AgentOperationDigest; readonly receiptsDigest: AgentOperationDigest; readonly finalAckHostSeq: number; readonly operations: readonly Readonly[] }; +export type AgentHostTurnTerminalAckV5 = Fenced & { readonly t: "turn_terminal_ack"; readonly ackHostSeq: number; readonly resultDigest: AgentOperationDigest; readonly receiptsDigest: AgentOperationDigest }; +export type AgentHostClientMessage = (Base & { readonly t: "hello" }) | AgentHostAttachV4 | AgentHostStartTurnV4 | AgentHostOperationReceiptV4 | AgentHostOperationQueryReceiptV4 | AgentHostOperationCancelReceiptV4 | AgentHostOperationStreamV4 | AgentHostConsumptionAckV5 | AgentHostTurnTerminalAckV5; +export type AgentHostServerMessage = (Base & { readonly t: "hello"; readonly accepted: true; readonly hostId: string; readonly hostGeneration: number; readonly hostIncarnation: string; readonly hostChallenge: string }) | AgentHostAttachedV4 | (Fenced & { readonly t: "turn_started"; readonly hostSeq: number }) | AgentHostOperationRequestV4 | AgentHostOperationQueryV4 | AgentHostOperationCancelV4 | AgentHostOperationStreamAckV4 | AgentHostTurnTerminalV5 | (Base & { readonly t: "error"; readonly code: "unsupported_version" | "invalid_request" | "stale_generation" | "host_busy" | "turn_failed"; readonly message: string; readonly fence?: Readonly }); + +function base(value: unknown, t: string, keys: readonly string[]): value is Record { return record(value) && exact(value, keys) && value.t === t && value.version === AGENT_HOST_PROTOCOL_VERSION && id(value.requestId); } +function fenced(value: unknown, t: string, tail: readonly string[]): value is Record { return base(value, t, ["t", "version", "requestId", "fence", ...tail]) && isAgentTurnFence(value.fence); } +function hostAsync(value: unknown, t: string, tail: readonly string[]): value is Record { return fenced(value, t, ["hostSeq", "operationId", ...tail]) && positive(value.hostSeq) && id(value.operationId); } +function gateway(value: unknown, t: string, tail: readonly string[]): value is Record { return fenced(value, t, ["ackHostSeq", "operationId", ...tail]) && uint(value.ackHostSeq) && id(value.operationId); } +function decodeResume(value: unknown): AgentHostAttachResumeCursorV4 | null | undefined { + if (value === null) return null; + if (!record(value) || !exact(value, ["lastHostSeq", "operations"]) || !uint(value.lastHostSeq) || !Array.isArray(value.operations) || value.operations.length > 8) return undefined; + const operations: { operationId: string; throughStreamSeq: number }[] = []; + for (const item of value.operations) { if (!record(item) || !exact(item, ["operationId", "throughStreamSeq"]) || !id(item.operationId) || !uint(item.throughStreamSeq)) return undefined; operations.push({ operationId: item.operationId, throughStreamSeq: item.throughStreamSeq }); } + if (operations.some((item, index) => index > 0 && operations[index - 1]!.operationId >= item.operationId)) return undefined; + return deepFreeze({ lastHostSeq: value.lastHostSeq, operations }); +} +export const decodeAgentHostAttachResumeCursorV4 = decodeResume; +function decodeOperationCursors(value: unknown): readonly Readonly<{ operationId: string; throughStreamSeq: number }>[] | undefined { + if (!Array.isArray(value) || value.length > MAX_AGENT_HOST_IN_FLIGHT_OPERATIONS) return undefined; + const operations: { operationId: string; throughStreamSeq: number }[] = []; + for (const item of value) { if (!record(item) || !exact(item, ["operationId", "throughStreamSeq"]) || !id(item.operationId) || !uint(item.throughStreamSeq)) return undefined; operations.push({ operationId: item.operationId, throughStreamSeq: item.throughStreamSeq }); } + if (operations.some((item, index) => index > 0 && operations[index - 1]!.operationId >= item.operationId)) return undefined; + return deepFreeze(operations); +} +function decodeExpected(value: unknown): ExpectedAgentHostSupervisionBindingsV3 | undefined { + const keys = ["fence", "planHash", "hostId", "hostGeneration", "hostIncarnation", "supervisorEpoch", "kernelServiceEpoch", "hostChallenge", "nonce", "audience", "purpose", "keyId", "issuedAtMs", "expiresAtMs"]; + if (!record(value) || !exact(value, keys) || !isAgentTurnFence(value.fence) || !digest(value.planHash) || !id(value.hostId) || !positive(value.hostGeneration) || !boundedName(value.hostIncarnation, 256) || !positive(value.supervisorEpoch) || !boundedName(value.kernelServiceEpoch, 256) || typeof value.hostChallenge !== "string" || !SUPERVISION_TOKEN_RE.test(value.hostChallenge) || typeof value.nonce !== "string" || !SUPERVISION_TOKEN_RE.test(value.nonce) || value.audience !== AGENT_HOST_SUPERVISION_AUDIENCE || value.purpose !== AGENT_HOST_SUPERVISION_PURPOSE || !boundedName(value.keyId, 256) || !uint(value.issuedAtMs) || !positive(value.expiresAtMs) || value.expiresAtMs <= value.issuedAtMs) return undefined; + return immutable(value) as unknown as ExpectedAgentHostSupervisionBindingsV3; +} +function decodeCanonicalBase64Url(value: unknown, exactBytes: number | undefined, maxBytes: number): value is string { + if (typeof value !== "string" || value.length < 2 || value.length % 4 === 1 || value.includes("=") || !/^[A-Za-z0-9_-]+$/.test(value)) return false; + try { + const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=")); + if (binary.length > maxBytes || (exactBytes !== undefined && binary.length !== exactBytes)) return false; + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") === value; + } catch { return false; } +} +function decodeEnvelope(value: unknown): SignedAgentHostSupervisionEnvelopeV1 | undefined { + if (!record(value) || !exact(value, ["version", "algorithm", "domain", "authorityBytes", "signature"]) || value.version !== 1 || value.algorithm !== "Ed25519" || value.domain !== "opensession.agent-host.supervision.v2" || !decodeCanonicalBase64Url(value.authorityBytes, undefined, 4096) || !decodeCanonicalBase64Url(value.signature, 64, 64)) return undefined; + return immutable(value) as unknown as SignedAgentHostSupervisionEnvelopeV1; +} +function decodeAttachReceipt(value: unknown): AgentHostSignedAttachReceiptV4 | undefined { + if (!record(value) || !exact(value, ["expected", "envelope"])) return undefined; + const expected = decodeExpected(value.expected), envelope = decodeEnvelope(value.envelope); + return expected && envelope ? deepFreeze({ expected, envelope }) : undefined; +} +function boundReceipt(value: unknown, operationId: string, fence: unknown): AgentOperationReceiptV1 | undefined { + const receipt = decodeAgentOperationReceiptV1(value); + return receipt && receipt.operationId === operationId && JSON.stringify(receipt.fence) === JSON.stringify(fence) ? receipt : undefined; +} +export function decodeAgentHostHello(value: unknown) { return base(value, "hello", ["t", "version", "requestId"]) ? immutable(value) as unknown as Extract : undefined; } +export function decodeAgentHostAttach(value: unknown): AgentHostAttachV4 | undefined { if (!fenced(value, "attach", ["planHash", "receipt", "resume"]) || !digest(value.planHash)) return; const receipt = decodeAttachReceipt(value.receipt), resume = decodeResume(value.resume); return receipt && resume !== undefined ? deepFreeze({ ...value, fence: decodeAgentTurnFence(value.fence)!, receipt, resume }) as AgentHostAttachV4 : undefined; } +export function decodeAgentHostAttached(value: unknown): AgentHostAttachedV4 | undefined { return fenced(value, "attached", ["planHash", "supervisorEpoch", "mode", "replayFromHostSeq"]) && digest(value.planHash) && positive(value.supervisorEpoch) && ["fresh", "resumed", "recovery_required"].includes(value.mode as string) && uint(value.replayFromHostSeq) ? immutable(value) as unknown as AgentHostAttachedV4 : undefined; } +export function decodeAgentHostTurnStarted(value: unknown): Extract | undefined { return fenced(value, "turn_started", ["hostSeq"]) && positive(value.hostSeq) ? immutable(value) as unknown as Extract : undefined; } +export function decodeAgentHostStartTurn(value: unknown, nowMs = Date.now()): AgentHostStartTurnV4 | undefined { if (!base(value, "start_turn", ["t", "version", "requestId", "planHash", "spec"]) || !digest(value.planHash)) return; const spec = decodeAgentTurnSpec(value.spec, nowMs); return spec ? deepFreeze({ ...value, spec }) as AgentHostStartTurnV4 : undefined; } +function decodeAgentHostOperationRequestStructure(value: unknown, nowMs: number, turnDeadlineMs: number): AgentHostOperationRequestV4 | undefined { if (!hostAsync(value, "operation_request", ["descriptor", "descriptorDigest", "deadlineMs"]) || !digest(value.descriptorDigest) || !positive(value.deadlineMs) || value.deadlineMs <= nowMs || value.deadlineMs > nowMs + MAX_AGENT_OPERATION_DURATION_MS || value.deadlineMs > turnDeadlineMs) return; const descriptor = decodeAgentOperationDescriptorV1(value.descriptor); return descriptor ? deepFreeze({ ...value, descriptor }) as AgentHostOperationRequestV4 : undefined; } +export async function decodeAgentHostOperationRequest(value: unknown, nowMs = Date.now(), turnDeadlineMs = nowMs + MAX_AGENT_TURN_DURATION_MS): Promise { const decoded = decodeAgentHostOperationRequestStructure(value, nowMs, turnDeadlineMs); return decoded && await hashAgentOperationDescriptorV1(decoded.descriptor) === decoded.descriptorDigest ? decoded : undefined; } +export const decodeAgentHostOperationRequestExact = decodeAgentHostOperationRequest; +export function decodeAgentHostOperationQuery(value: unknown): AgentHostOperationQueryV4 | undefined { return hostAsync(value, "operation_query", ["kind", "descriptorDigest", "payloadDigest", "afterStreamSeq"]) && (value.kind === "model" || value.kind === "mcp") && digest(value.descriptorDigest) && digest(value.payloadDigest) && uint(value.afterStreamSeq) ? immutable(value) as unknown as AgentHostOperationQueryV4 : undefined; } +export function decodeAgentHostOperationCancel(value: unknown): AgentHostOperationCancelV4 | undefined { return hostAsync(value, "operation_cancel", ["cancelId", "reason"]) && id(value.cancelId) && ["user", "turn_deadline", "shutdown", "reconnect_deadline"].includes(value.reason as string) ? immutable(value) as unknown as AgentHostOperationCancelV4 : undefined; } +export function decodeAgentHostOperationReceipt(value: unknown): AgentHostOperationReceiptV4 | undefined { if (!gateway(value, "operation_receipt", ["receipt"])) return; const receipt = boundReceipt(value.receipt, value.operationId as string, value.fence); return receipt ? deepFreeze({ ...value, receipt }) as AgentHostOperationReceiptV4 : undefined; } +export function decodeAgentHostOperationQueryReceipt(value: unknown): AgentHostOperationQueryReceiptV4 | undefined { if (!gateway(value, "operation_query_receipt", ["fromStreamSeq", "receipt"]) || !positive(value.fromStreamSeq)) return; const receipt = boundReceipt(value.receipt, value.operationId as string, value.fence); return receipt ? deepFreeze({ ...value, receipt }) as AgentHostOperationQueryReceiptV4 : undefined; } +export function decodeAgentHostOperationCancelReceipt(value: unknown): AgentHostOperationCancelReceiptV4 | undefined { if (!gateway(value, "operation_cancel_receipt", ["cancelId", "disposition", "receipt"]) || !id(value.cancelId) || !["not_started", "cancelled", "too_late", "indeterminate"].includes(value.disposition as string)) return; const receipt = boundReceipt(value.receipt, value.operationId as string, value.fence); return receipt ? deepFreeze({ ...value, receipt }) as AgentHostOperationCancelReceiptV4 : undefined; } +function canonicalStreamBytes(value: unknown): value is string { if (typeof value !== "string" || value.length < 2 || value.length > 65_536 || !/^[A-Za-z0-9_-]+$/.test(value)) return false; try { const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4); const binary = atob(padded); if (binary.length < 1 || binary.length > MAX_AGENT_HOST_STREAM_CHUNK_BYTES) return false; return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") === value; } catch { return false; } } +export function decodeAgentHostOperationStream(value: unknown): AgentHostOperationStreamV4 | undefined { return fenced(value, "operation_stream", ["operationId", "streamSeq", "encoding", "bytes"]) && id(value.operationId) && positive(value.streamSeq) && value.encoding === STREAM_ENCODING && canonicalStreamBytes(value.bytes) ? immutable(value) as unknown as AgentHostOperationStreamV4 : undefined; } +export function decodeAgentHostOperationStreamAck(value: unknown): AgentHostOperationStreamAckV4 | undefined { return hostAsync(value, "operation_stream_ack", ["throughStreamSeq", "creditBytes", "creditChunks"]) && uint(value.throughStreamSeq) && uint(value.creditBytes) && value.creditBytes <= MAX_AGENT_HOST_STREAM_BYTES && uint(value.creditChunks) && value.creditChunks <= MAX_AGENT_HOST_STREAM_CHUNKS ? immutable(value) as unknown as AgentHostOperationStreamAckV4 : undefined; } +export function decodeAgentHostConsumptionAck(value: unknown): AgentHostConsumptionAckV5 | undefined { if (!fenced(value, "consumption_ack", ["ackHostSeq", "operations"]) || !uint(value.ackHostSeq)) return; const operations = decodeOperationCursors(value.operations); return operations ? deepFreeze({ ...value, operations }) as AgentHostConsumptionAckV5 : undefined; } +export function decodeAgentHostTurnTerminal(value: unknown): AgentHostTurnTerminalV5 | undefined { + if (!fenced(value, "turn_terminal", ["hostSeq", "hostGeneration", "hostIncarnation", "result", "resultDigest", "receiptsDigest", "finalAckHostSeq", "operations"]) || !positive(value.hostSeq) || !positive(value.hostGeneration) || !boundedName(value.hostIncarnation, 256) || !record(value.result) || !exact(value.result, ["status"]) || !["completed", "cancelled", "failed"].includes(value.result.status as string) || !digest(value.resultDigest) || !digest(value.receiptsDigest) || !uint(value.finalAckHostSeq) || value.finalAckHostSeq >= value.hostSeq) return; + if (!Array.isArray(value.operations) || value.operations.length > MAX_AGENT_HOST_IN_FLIGHT_OPERATIONS) return; + const operations: AgentHostTerminalOperationV5[] = []; + for (const item of value.operations) { if (!record(item) || !exact(item, ["operationId", "receiptDigest", "throughStreamSeq"]) || !id(item.operationId) || !digest(item.receiptDigest) || !uint(item.throughStreamSeq)) return; operations.push({ operationId: item.operationId, receiptDigest: item.receiptDigest, throughStreamSeq: item.throughStreamSeq }); } + if (operations.some((item, index) => index > 0 && operations[index - 1]!.operationId >= item.operationId)) return; + return deepFreeze({ ...value, operations }) as unknown as AgentHostTurnTerminalV5; +} +export function decodeAgentHostTurnTerminalAck(value: unknown): AgentHostTurnTerminalAckV5 | undefined { return fenced(value, "turn_terminal_ack", ["ackHostSeq", "resultDigest", "receiptsDigest"]) && positive(value.ackHostSeq) && digest(value.resultDigest) && digest(value.receiptsDigest) ? immutable(value) as unknown as AgentHostTurnTerminalAckV5 : undefined; } +const AGENT_TURN_RESULT_HASH_DOMAIN = "OpenSession-Agent-Turn-Result-v1\0"; +const AGENT_TURN_RECEIPTS_HASH_DOMAIN = "OpenSession-Agent-Turn-Receipts-v1\0"; +async function hashTerminalValue(domain: string, value: unknown): Promise { const bytes = textEncoder.encode(`${domain}${JSON.stringify(canonical(value))}`); const result = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); return `sha256:${[...result].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; } +export function hashAgentTurnResultV1(result: Readonly<{ status: "completed" | "cancelled" } | { status: "failed"; error: string }>): Promise { return hashTerminalValue(AGENT_TURN_RESULT_HASH_DOMAIN, result); } +export async function projectAgentTurnTerminalOperationsV1(operations: readonly Readonly<{ operationId: string; receipt: AgentOperationReceiptV1; throughStreamSeq: number }>[]): Promise[]> { const projected = await Promise.all(operations.map(async (operation) => ({ operationId: operation.operationId, receiptDigest: await hashAgentOperationReceiptV1(operation.receipt), throughStreamSeq: operation.throughStreamSeq }))); projected.sort((a, b) => a.operationId.localeCompare(b.operationId)); return deepFreeze(projected); } +export function hashAgentTurnTerminalReceiptsV1(operations: readonly Readonly[]): Promise { return hashTerminalValue(AGENT_TURN_RECEIPTS_HASH_DOMAIN, operations); } + +export interface AgentHostSupervisionAuthorityV2 { + readonly version: typeof AGENT_HOST_SUPERVISION_VERSION; + readonly fence: Readonly; + readonly planHash: string; + readonly hostId: string; + readonly hostGeneration: number; + readonly hostIncarnation: string; + readonly supervisorEpoch: number; + readonly kernelServiceEpoch: string; + readonly hostChallenge: string; + readonly audience: typeof AGENT_HOST_SUPERVISION_AUDIENCE; + readonly purpose: typeof AGENT_HOST_SUPERVISION_PURPOSE; + readonly issuedAtMs: number; + readonly expiresAtMs: number; + readonly nonce: string; + readonly keyId: string; +} + +const SUPERVISION_KEYS = [ + "version", + "fence", + "planHash", + "hostId", + "hostGeneration", + "hostIncarnation", + "supervisorEpoch", + "kernelServiceEpoch", + "hostChallenge", + "audience", + "purpose", + "issuedAtMs", + "expiresAtMs", + "nonce", + "keyId", +] as const; + +/** Strict structural decode. Time admission is optional so persisted receipts + * remain decodable after expiry. Unknown fields fail closed. */ +export function decodeAgentHostSupervisionAuthorityV2( + value: unknown, + nowMs?: number, +): AgentHostSupervisionAuthorityV2 | undefined { + if ( + !record(value) || + Object.keys(value).length !== SUPERVISION_KEYS.length || + !exact(value, SUPERVISION_KEYS) + ) + return undefined; + if ( + value.version !== AGENT_HOST_SUPERVISION_VERSION || + !isAgentTurnFence(value.fence) || + typeof value.planHash !== "string" || + !SHA256_RE.test(value.planHash) || + !decodeExecutorId(value.hostId) || + !Number.isSafeInteger(value.hostGeneration) || + (value.hostGeneration as number) < 1 || + !boundedName(value.hostIncarnation, 256) || + !Number.isSafeInteger(value.supervisorEpoch) || + (value.supervisorEpoch as number) < 1 || + !boundedName(value.kernelServiceEpoch, 256) || + typeof value.hostChallenge !== "string" || + !SUPERVISION_TOKEN_RE.test(value.hostChallenge) || + value.audience !== AGENT_HOST_SUPERVISION_AUDIENCE || + value.purpose !== AGENT_HOST_SUPERVISION_PURPOSE || + !Number.isSafeInteger(value.issuedAtMs) || + (value.issuedAtMs as number) < 0 || + !Number.isSafeInteger(value.expiresAtMs) || + (value.expiresAtMs as number) <= (value.issuedAtMs as number) || + (value.expiresAtMs as number) - (value.issuedAtMs as number) > + MAX_AGENT_HOST_SUPERVISION_LEASE_MS || + typeof value.nonce !== "string" || + !SUPERVISION_TOKEN_RE.test(value.nonce) || + !boundedName(value.keyId, 256) + ) + return undefined; + if ( + nowMs !== undefined && + ((value.issuedAtMs as number) > + nowMs + MAX_AGENT_HOST_SUPERVISION_CLOCK_SKEW_MS || + (value.expiresAtMs as number) <= nowMs) + ) + return undefined; + + return Object.freeze({ + version: AGENT_HOST_SUPERVISION_VERSION, + fence: Object.freeze({ ...(value.fence as AgentTurnFence) }), + planHash: value.planHash, + hostId: value.hostId, + hostGeneration: value.hostGeneration, + hostIncarnation: value.hostIncarnation, + supervisorEpoch: value.supervisorEpoch, + kernelServiceEpoch: value.kernelServiceEpoch, + hostChallenge: value.hostChallenge, + audience: AGENT_HOST_SUPERVISION_AUDIENCE, + purpose: AGENT_HOST_SUPERVISION_PURPOSE, + issuedAtMs: value.issuedAtMs, + expiresAtMs: value.expiresAtMs, + nonce: value.nonce, + keyId: value.keyId, + } as AgentHostSupervisionAuthorityV2); +} + +/** Canonical UTF-8 JSON with a fixed field order. The bytes, not a mutable + * object supplied by a gateway, are the future signer's input. */ +export function serializeAgentHostSupervisionAuthorityV2( + value: AgentHostSupervisionAuthorityV2, +): Uint8Array { + const decoded = decodeAgentHostSupervisionAuthorityV2(value); + if (!decoded) throw new Error("Invalid Agent Host supervision authority"); + return textEncoder.encode( + JSON.stringify({ + version: decoded.version, + fence: { + sessionId: decoded.fence.sessionId, + runId: decoded.fence.runId, + turnId: decoded.fence.turnId, + generation: decoded.fence.generation, + }, + planHash: decoded.planHash, + hostId: decoded.hostId, + hostGeneration: decoded.hostGeneration, + hostIncarnation: decoded.hostIncarnation, + supervisorEpoch: decoded.supervisorEpoch, + kernelServiceEpoch: decoded.kernelServiceEpoch, + hostChallenge: decoded.hostChallenge, + audience: decoded.audience, + purpose: decoded.purpose, + issuedAtMs: decoded.issuedAtMs, + expiresAtMs: decoded.expiresAtMs, + nonce: decoded.nonce, + keyId: decoded.keyId, + }), + ); +} + +export async function hashAgentHostSupervisionAuthorityV2( + value: AgentHostSupervisionAuthorityV2, +): Promise { + const bytes = serializeAgentHostSupervisionAuthorityV2(value); + const source = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", source)); + return `sha256:${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} diff --git a/packages/core/protocol/src/agent-operation.test.ts b/packages/core/protocol/src/agent-operation.test.ts new file mode 100644 index 0000000000..907618fa25 --- /dev/null +++ b/packages/core/protocol/src/agent-operation.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, test } from "bun:test"; +import { encodeExecutorGrant } from "./executor"; +import { + AGENT_GATEWAY_DISPATCH_GRANT_PREFIX, + AGENT_MCP_ARGUMENTS_DIGEST_DOMAIN, + AGENT_MCP_PAYLOAD_DIGEST_DOMAIN, + AGENT_MODEL_PAYLOAD_DIGEST_DOMAIN, + AGENT_OPERATION_DESCRIPTOR_DIGEST_DOMAIN, + AGENT_OPERATION_RECEIPT_DIGEST_DOMAIN, + MAX_AGENT_OPERATION_DEPTH, + decodeAgentGatewayDispatchGrant, + decodeAgentOperationDescriptorV1, + decodeAgentOperationQueryV1, + decodeAgentOperationReceiptV1, + decodeAgentOperationRequestV1, + decodeAgentTranscriptReceiptRefV1, + encodeAgentGatewayDispatchGrant, + hashAgentMcpArgumentsV1, + hashAgentMcpPayloadV1, + hashAgentModelPayloadV1, + hashAgentOperationDescriptorV1, + hashAgentOperationReceiptV1, + serializeAgentOperationDescriptorV1, + serializeAgentOperationQueryV1, + serializeAgentOperationReceiptV1, + serializeAgentOperationRequestV1, + unsupportedAgentOperationReconciliation, + type AgentModelOperationDescriptorV1, + type AgentOperationReceiptV1, +} from "./agent-operation"; + +const d = (char: string) => `sha256:${char.repeat(64)}` as const; +const fence = { + sessionId: "session-1", + runId: "run-1", + turnId: "turn-1", + generation: 3, +}; +const grant = encodeAgentGatewayDispatchGrant("g".repeat(43)); +const supervisionEnvelope = { + version: 1 as const, + algorithm: "Ed25519" as const, + domain: "opensession.agent-host.supervision.v2" as const, + authorityBytes: "YQ", + signature: "A".repeat(86), +}; +const model: AgentModelOperationDescriptorV1 = { + version: 1, + kind: "model", + stepId: "step-1", + transcript: { throughChangeSeq: 4, entryIds: ["entry-1"], digest: d("a") }, + modelPolicyHash: d("b"), + adapterRequestVersion: "model-request.v1", +}; +const request = { + version: 1 as const, + operationId: "op-1", + kind: "model" as const, + fence, + supervisionEnvelope, + dispatchGrant: grant, + descriptor: model, + descriptorDigest: d("c"), +}; +const receipt: AgentOperationReceiptV1 = { + version: 1, + operationId: "op-1", + kind: "model", + fence, + planHash: d("d"), + authorityHash: d("e"), + descriptorDigest: d("c"), + payloadDigest: d("f"), + actorIdentity: { + supervisorEpoch: 4, + hostId: "host-1", + hostGeneration: 2, + hostIncarnation: "incarnation-1", + transcriptAnchor: model.transcript, + }, + state: "prepared", + acceptedAtMs: 1, + providerRef: { adapterId: "adapter-1", adapterVersion: "1.0" }, +}; + +describe("Agent operation protocol v1", () => { + test("brands a gateway grant in a domain separate from Agent Host and Executor grants", () => { + expect(decodeAgentGatewayDispatchGrant(grant)).toBe(grant); + expect(grant.startsWith(AGENT_GATEWAY_DISPATCH_GRANT_PREFIX)).toBe(true); + expect( + decodeAgentGatewayDispatchGrant(encodeExecutorGrant("e".repeat(32))), + ).toBeUndefined(); + expect(() => encodeAgentGatewayDispatchGrant("short")).toThrow(); + }); + + test("strictly decodes exact request, kind and query authority bindings", () => { + expect(decodeAgentOperationRequestV1(request)).toEqual(request); + expect( + decodeAgentOperationRequestV1({ ...request, extra: true }), + ).toBeUndefined(); + expect( + decodeAgentOperationRequestV1({ ...request, version: 2 }), + ).toBeUndefined(); + expect( + decodeAgentOperationRequestV1({ ...request, kind: "mcp" }), + ).toBeUndefined(); + expect( + decodeAgentOperationRequestV1({ ...request, descriptorDigest: d("C") }), + ).toBeUndefined(); + const query = { + version: 1 as const, + operationId: "op-1", + kind: "model" as const, + fence, + supervisionEnvelope, + dispatchGrant: grant, + descriptorDigest: d("c"), + payloadDigest: d("f"), + }; + expect(decodeAgentOperationQueryV1(query)).toEqual(query); + expect( + decodeAgentOperationQueryV1({ + ...query, + operationId: "op-1", + fence: { ...fence, generation: 4 }, + }), + ).toBeDefined(); + expect( + decodeAgentOperationQueryV1({ version: 1, operationId: "op-1" }), + ).toBeUndefined(); + }); + + test("rejects descriptor crossover, raw bodies and recursively forbidden secret/config keys", () => { + expect(decodeAgentOperationDescriptorV1(model)).toEqual(model); + expect( + decodeAgentOperationDescriptorV1({ ...model, prompt: "secret" }), + ).toBeUndefined(); + expect( + decodeAgentOperationDescriptorV1({ + ...model, + transcript: { ...model.transcript, headers: { Authorization: "x" } }, + }), + ).toBeUndefined(); + expect( + decodeAgentOperationDescriptorV1({ ...model, kind: "mcp" }), + ).toBeUndefined(); + expect( + decodeAgentOperationDescriptorV1({ + version: 1, + kind: "mcp", + toolUseEntryId: "e", + toolUseId: "t", + server: "s", + tool: "x", + argumentsDigest: d("a"), + adapterRequestVersion: "v1", + arguments: {}, + }), + ).toBeUndefined(); + }); + + test("rejects non-JSON values, prototypes, nonfinite numbers and excessive depth", () => { + expect( + decodeAgentOperationDescriptorV1( + Object.assign(Object.create({ inherited: true }), model), + ), + ).toBeUndefined(); + expect( + decodeAgentOperationDescriptorV1({ + ...model, + transcript: { ...model.transcript, throughChangeSeq: Infinity }, + }), + ).toBeUndefined(); + let nested: unknown = "leaf"; + for (let i = 0; i < MAX_AGENT_OPERATION_DEPTH + 2; i++) nested = { nested }; + expect( + decodeAgentOperationDescriptorV1({ ...model, extra: nested }), + ).toBeUndefined(); + expect( + decodeAgentOperationDescriptorV1({ + ...model, + transcript: { ...model.transcript, entryIds: [undefined] }, + }), + ).toBeUndefined(); + let reads = 0; + const accessor = { ...model } as Record; + Object.defineProperty(accessor, "stepId", { + enumerable: true, + get: () => (++reads === 1 ? "step-1" : "secret-value"), + }); + expect(decodeAgentOperationDescriptorV1(accessor)).toBeUndefined(); + const accessorIds = ["entry-1"]; + Object.defineProperty(accessorIds, "0", { + enumerable: true, + get: () => "secret-value", + }); + expect( + decodeAgentOperationDescriptorV1({ + ...model, + transcript: { ...model.transcript, entryIds: accessorIds }, + }), + ).toBeUndefined(); + const proxy = new Proxy({ ...model }, {}); + expect(decodeAgentOperationDescriptorV1(proxy)).toBeUndefined(); + }); + + test("uses deterministic canonical serialization and distinct digest domains", async () => { + const bytes = serializeAgentOperationDescriptorV1(model); + expect(new TextDecoder().decode(bytes)).toBe( + '{"version":1,"kind":"model","stepId":"step-1","transcript":{"throughChangeSeq":4,"entryIds":["entry-1"],"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"modelPolicyHash":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","adapterRequestVersion":"model-request.v1"}', + ); + const values = await Promise.all([ + hashAgentOperationDescriptorV1(model), + hashAgentModelPayloadV1(bytes), + hashAgentMcpPayloadV1(bytes), + hashAgentMcpArgumentsV1(bytes), + ]); + expect(new Set(values).size).toBe(4); + expect( + serializeAgentOperationRequestV1({ + ...request, + fence: { + generation: 3, + turnId: "turn-1", + runId: "run-1", + sessionId: "session-1", + }, + }), + ).toEqual(serializeAgentOperationRequestV1(request)); + const query = { + version: 1 as const, + operationId: "op-1", + kind: "model" as const, + fence, + supervisionEnvelope, + dispatchGrant: grant, + descriptorDigest: d("c"), + payloadDigest: d("f"), + }; + expect( + serializeAgentOperationQueryV1({ + payloadDigest: query.payloadDigest, + descriptorDigest: query.descriptorDigest, + dispatchGrant: query.dispatchGrant, + supervisionEnvelope: query.supervisionEnvelope, + fence: query.fence, + kind: query.kind, + operationId: query.operationId, + version: query.version, + }), + ).toEqual(serializeAgentOperationQueryV1(query)); + expect([ + AGENT_OPERATION_DESCRIPTOR_DIGEST_DOMAIN, + AGENT_OPERATION_RECEIPT_DIGEST_DOMAIN, + AGENT_MODEL_PAYLOAD_DIGEST_DOMAIN, + AGENT_MCP_PAYLOAD_DIGEST_DOMAIN, + AGENT_MCP_ARGUMENTS_DIGEST_DOMAIN, + ]).toHaveLength(5); + }); + + test("strictly decodes immutable transcript destination receipt references", () => { + const reference = { + appendId: "append-1", + entryIds: ["entry-1", "entry-2"], + firstSeq: 4, + lastSeq: 5, + throughChangeSeq: 8, + requestDigest: d("a"), + }; + const decoded = decodeAgentTranscriptReceiptRefV1(reference); + expect(decoded).toEqual(reference); + expect(Object.isFrozen(decoded)).toBe(true); + expect(Object.isFrozen(decoded!.entryIds)).toBe(true); + for (const invalid of [ + { ...reference, unknown: true }, + { ...reference, entryIds: [] }, + { ...reference, entryIds: ["entry-1", "entry-1"] }, + { ...reference, firstSeq: 0, lastSeq: 1 }, + { ...reference, lastSeq: 6 }, + { ...reference, throughChangeSeq: 0 }, + { ...reference, requestDigest: "sha256:not-a-digest" }, + ]) + expect(decodeAgentTranscriptReceiptRefV1(invalid)).toBeUndefined(); + const accessor = { ...reference }; + Object.defineProperty(accessor, "lastSeq", { + enumerable: true, + get: () => 5, + }); + expect(decodeAgentTranscriptReceiptRefV1(accessor)).toBeUndefined(); + expect( + decodeAgentTranscriptReceiptRefV1(new Proxy(reference, {})), + ).toBeUndefined(); + }); + + test("durably binds exact SessionKernel terminal replay material", async () => { + const transcriptRefs = [ + { + appendId: "append-1", + entryIds: ["entry-2", "tool-1"], + firstSeq: 5, + lastSeq: 6, + throughChangeSeq: 6, + requestDigest: d("1"), + }, + ]; + const kernelTerminal = { + outputDigest: d("2"), + outcomeCode: "ok", + transcriptRefs, + pendingToolUseEntryIds: ["tool-1"], + }; + const terminal: AgentOperationReceiptV1 = { + ...receipt, + state: "settled", + executingAtMs: 2, + completedAtMs: 3, + outcome: { + status: "succeeded", + code: "ok", + outputDigest: d("2"), + }, + transcriptRefs, + kernelTerminal, + }; + expect(decodeAgentOperationReceiptV1(terminal)).toEqual(terminal); + expect(await hashAgentOperationReceiptV1(terminal)).toMatch( + /^sha256:[a-f0-9]{64}$/, + ); + expect(serializeAgentOperationReceiptV1(terminal)).toEqual( + serializeAgentOperationReceiptV1( + decodeAgentOperationReceiptV1(terminal)!, + ), + ); + expect( + decodeAgentOperationReceiptV1({ + ...terminal, + kernelTerminal: { + ...kernelTerminal, + pendingToolUseEntryIds: ["missing-tool"], + }, + }), + ).toBeUndefined(); + expect( + decodeAgentOperationReceiptV1({ + ...terminal, + kernelTerminal: { ...kernelTerminal, outputDigest: d("3") }, + }), + ).toBeUndefined(); + expect( + decodeAgentOperationReceiptV1({ + ...terminal, + kernelTerminal: undefined, + }), + ).toBeUndefined(); + const indeterminate = { + ...receipt, + state: "indeterminate" as const, + executingAtMs: 2, + completedAtMs: 3, + transcriptRefs, + errorCode: "reconciliation_failed" as const, + kernelTerminal: { + ...kernelTerminal, + outcomeCode: "reconciliation_failed", + }, + }; + expect(decodeAgentOperationReceiptV1(indeterminate)).toEqual(indeterminate); + expect( + decodeAgentOperationReceiptV1({ + ...indeterminate, + kernelTerminal: { + ...indeterminate.kernelTerminal, + outcomeCode: "ambiguous_completion", + }, + }), + ).toBeUndefined(); + }); + + test("strictly binds terminal reservations only to executing receipts", () => { + const terminalReservation = { + reservationId: `reservation:${"1".repeat(64)}`, + reason: "reconciliation_unsupported" as const, + reservedAtMs: 3, + }; + const executing = { + ...receipt, + state: "executing" as const, + executingAtMs: 2, + terminalReservation, + }; + expect(decodeAgentOperationReceiptV1(executing)).toEqual(executing); + expect(serializeAgentOperationReceiptV1(executing)).toEqual( + serializeAgentOperationReceiptV1( + decodeAgentOperationReceiptV1(executing)!, + ), + ); + expect( + decodeAgentOperationReceiptV1({ + ...executing, + terminalReservation: { + ...terminalReservation, + reservedAtMs: 1, + }, + }), + ).toBeUndefined(); + expect( + decodeAgentOperationReceiptV1({ + ...executing, + terminalReservation: { + ...terminalReservation, + reason: "operation_failed", + }, + }), + ).toBeUndefined(); + expect( + decodeAgentOperationReceiptV1({ + ...receipt, + terminalReservation, + }), + ).toBeUndefined(); + }); + + test("receipts are strict bounded metadata and cannot contain bodies or secrets", () => { + expect(decodeAgentOperationReceiptV1(receipt)).toEqual(receipt); + expect( + new TextDecoder().decode(serializeAgentOperationReceiptV1(receipt)), + ).not.toMatch(/body|prompt|credential|secret/i); + expect( + serializeAgentOperationReceiptV1({ + providerRef: receipt.providerRef, + acceptedAtMs: receipt.acceptedAtMs, + state: receipt.state, + payloadDigest: receipt.payloadDigest, + descriptorDigest: receipt.descriptorDigest, + authorityHash: receipt.authorityHash, + actorIdentity: receipt.actorIdentity, + planHash: receipt.planHash, + fence: receipt.fence, + kind: receipt.kind, + operationId: receipt.operationId, + version: receipt.version, + }), + ).toEqual(serializeAgentOperationReceiptV1(receipt)); + for (const forbidden of [ + "body", + "prompt", + "credentials", + "headers", + "url", + "arguments", + ]) + expect( + decodeAgentOperationReceiptV1({ ...receipt, [forbidden]: "x" }), + ).toBeUndefined(); + expect( + decodeAgentOperationReceiptV1({ + ...receipt, + state: "settled", + completedAtMs: 2, + }), + ).toBeUndefined(); + expect( + decodeAgentOperationReceiptV1({ + ...receipt, + providerRef: { ...receipt.providerRef, requestId: "contains spaces" }, + }), + ).toBeUndefined(); + }); + + test("default reconciliation is explicitly fail-closed", async () => { + expect(await unsupportedAgentOperationReconciliation()).toEqual({ + status: "indeterminate", + reason: "reconciliation_unsupported", + }); + }); +}); diff --git a/packages/core/protocol/src/agent-operation.ts b/packages/core/protocol/src/agent-operation.ts new file mode 100644 index 0000000000..b9cec41d96 --- /dev/null +++ b/packages/core/protocol/src/agent-operation.ts @@ -0,0 +1,1149 @@ +import type { SignedAgentHostSupervisionEnvelopeV1 } from "./agent-host-supervision"; +import { isAgentTurnFence, type AgentTurnFence } from "./agent-host-fence"; + +export const AGENT_OPERATION_VERSION = 1 as const; +export const AGENT_GATEWAY_DISPATCH_GRANT_PREFIX = "osag_dispatch_v1." as const; +export const AGENT_OPERATION_DESCRIPTOR_DIGEST_DOMAIN = + "opensession.agent-operation.descriptor.v1"; +export const AGENT_OPERATION_RECEIPT_DIGEST_DOMAIN = + "opensession.agent-operation.receipt.v1"; +export const AGENT_MODEL_PAYLOAD_DIGEST_DOMAIN = + "opensession.agent-operation.model-payload.v1"; +export const AGENT_MCP_PAYLOAD_DIGEST_DOMAIN = + "opensession.agent-operation.mcp-payload.v1"; +export const AGENT_MCP_ARGUMENTS_DIGEST_DOMAIN = + "opensession.agent-operation.mcp-arguments.v1"; +export const MAX_AGENT_OPERATION_BYTES = 64 * 1024; +export const MAX_AGENT_OPERATION_DEPTH = 12; +export const MAX_AGENT_OPERATION_VALUES = 2_048; +const MAX_IDS = 512; +const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; +const VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/; +const DIGEST = /^sha256:[a-f0-9]{64}$/; +const REF = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511}$/; +const OUTCOME_CODES: readonly AgentOperationOutcomeCodeV1[] = [ + "ok", + "policy_rejected", + "invalid_request", + "provider_error", + "tool_error", + "result_too_large", + "cancelled", + "deadline_exceeded", +]; +const STOP_REASONS: readonly AgentOperationStopReasonV1[] = [ + "end_turn", + "tool_use", + "max_tokens", + "stop_sequence", + "cancelled", + "error", +]; +const RESERVATION_REASONS = [ + "reconciliation_unsupported", + "reconciliation_failed", + "ambiguous_completion", + "identity_mismatch", + "cancellation_ambiguous", + "timeout_ambiguous", + "disconnect_ambiguous", +] as const; +const ERROR_CODES = [ + "reconciliation_unsupported", + "reconciliation_failed", + "ambiguous_completion", + "identity_mismatch", + "cancellation_ambiguous", + "timeout_ambiguous", + "disconnect_ambiguous", + "invalid_response", + "operation_failed", +] as const; +const ENTROPY = /^[A-Za-z0-9_-]{43,512}$/; +const FORBIDDEN = + /^(?:accountId|apiKey|args|arguments|authorization|authToken|baseUrl|body|cookie|credentials?|env|environment|headers?|password|prompt|providerConfig|requestBody|responseBody|secret|token|accessToken|url)$/i; +const encoder = new TextEncoder(); +const record = (v: unknown): v is Record => + !!v && + typeof v === "object" && + !Array.isArray(v) && + Object.getPrototypeOf(v) === Object.prototype; +const exact = (v: Record, keys: readonly string[]) => + Object.keys(v).length === keys.length && + Object.keys(v).every((key) => keys.includes(key)); +const validId = (v: unknown): v is string => + typeof v === "string" && ID.test(v); +const validDigest = (v: unknown): v is AgentOperationDigest => + typeof v === "string" && DIGEST.test(v); +const time = (v: unknown): v is number => + Number.isSafeInteger(v) && (v as number) >= 0; +function canonicalBase64Url(value: unknown, exactBytes: number | undefined, maxBytes: number): value is string { + if (typeof value !== "string" || value.length < 2 || value.length % 4 === 1 || value.includes("=") || !/^[A-Za-z0-9_-]+$/.test(value)) return false; + try { + const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=")); + if (binary.length > maxBytes || (exactBytes !== undefined && binary.length !== exactBytes)) return false; + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "") === value; + } catch { return false; } +} +function decodeSignedAgentHostSupervisionEnvelopeV1(value: unknown): SignedAgentHostSupervisionEnvelopeV1 | undefined { + if (!record(value) || !exact(value, ["version", "algorithm", "domain", "authorityBytes", "signature"]) || value.version !== 1 || value.algorithm !== "Ed25519" || value.domain !== "opensession.agent-host.supervision.v2" || !canonicalBase64Url(value.authorityBytes, undefined, 4096) || !canonicalBase64Url(value.signature, 64, 64)) return undefined; + return Object.freeze({ version: 1, algorithm: "Ed25519", domain: "opensession.agent-host.supervision.v2", authorityBytes: value.authorityBytes, signature: value.signature }); +} + +export type AgentOperationDigest = `sha256:${string}`; +declare const grantBrand: unique symbol; +export type AgentGatewayDispatchGrant = string & { + readonly [grantBrand]: "AgentGatewayDispatchGrant"; +}; +export function encodeAgentGatewayDispatchGrant( + entropy: string, +): AgentGatewayDispatchGrant { + if (!ENTROPY.test(entropy)) + throw new TypeError("invalid gateway dispatch grant entropy"); + return `${AGENT_GATEWAY_DISPATCH_GRANT_PREFIX}${entropy}` as AgentGatewayDispatchGrant; +} +export function decodeAgentGatewayDispatchGrant( + value: unknown, +): AgentGatewayDispatchGrant | undefined { + if ( + typeof value !== "string" || + !value.startsWith(AGENT_GATEWAY_DISPATCH_GRANT_PREFIX) || + !ENTROPY.test(value.slice(AGENT_GATEWAY_DISPATCH_GRANT_PREFIX.length)) + ) + return undefined; + return value as AgentGatewayDispatchGrant; +} + +export interface AgentTranscriptAnchorV1 { + throughChangeSeq: number; + entryIds: readonly string[]; + digest: AgentOperationDigest; +} +export interface AgentModelOperationDescriptorV1 { + version: 1; + kind: "model"; + stepId: string; + transcript: AgentTranscriptAnchorV1; + modelPolicyHash: AgentOperationDigest; + adapterRequestVersion: string; +} +export interface AgentMcpOperationDescriptorV1 { + version: 1; + kind: "mcp"; + toolUseEntryId: string; + toolUseId: string; + server: string; + tool: string; + argumentsDigest: AgentOperationDigest; + adapterRequestVersion: string; +} +export type AgentOperationDescriptorV1 = + AgentModelOperationDescriptorV1 | AgentMcpOperationDescriptorV1; +export type AgentOperationKind = AgentOperationDescriptorV1["kind"]; +export interface AgentOperationRequestV1 { + version: 1; + operationId: string; + kind: AgentOperationKind; + fence: Readonly; + supervisionEnvelope: SignedAgentHostSupervisionEnvelopeV1; + dispatchGrant: AgentGatewayDispatchGrant; + descriptor: AgentOperationDescriptorV1; + descriptorDigest: AgentOperationDigest; +} +export interface AgentOperationQueryV1 { + version: 1; + operationId: string; + kind: AgentOperationKind; + fence: Readonly; + supervisionEnvelope: SignedAgentHostSupervisionEnvelopeV1; + dispatchGrant: AgentGatewayDispatchGrant; + descriptorDigest: AgentOperationDigest; + payloadDigest: AgentOperationDigest; +} +export type AgentOperationState = + "prepared" | "executing" | "settled" | "indeterminate"; +export interface AgentOperationUsageV1 { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + costUsd?: number; +} +export type AgentOperationOutcomeCodeV1 = + | "ok" + | "policy_rejected" + | "invalid_request" + | "provider_error" + | "tool_error" + | "result_too_large" + | "cancelled" + | "deadline_exceeded"; +export type AgentOperationStopReasonV1 = + | "end_turn" + | "tool_use" + | "max_tokens" + | "stop_sequence" + | "cancelled" + | "error"; +export interface AgentTranscriptReceiptRefV1 { + appendId: string; + entryIds: readonly string[]; + firstSeq: number; + lastSeq: number; + throughChangeSeq: number; + requestDigest: AgentOperationDigest; +} +export interface AgentOperationOutcomeV1 { + status: "succeeded" | "failed" | "cancelled"; + code?: AgentOperationOutcomeCodeV1; + outputDigest?: AgentOperationDigest; + usage?: AgentOperationUsageV1; + stopReason?: AgentOperationStopReasonV1; +} +export interface AgentOperationProviderRefV1 { + adapterId: string; + adapterVersion: string; + requestId?: string; + responseId?: string; +} +export interface AgentOperationActorIdentityV1 { + supervisorEpoch: number; + hostId: string; + hostGeneration: number; + hostIncarnation: string; + transcriptAnchor: Readonly; + toolUseEntryId?: string; +} +export interface AgentOperationTerminalReservationV1 { + reservationId: string; + reason: (typeof RESERVATION_REASONS)[number]; + reservedAtMs: number; +} +export interface AgentOperationKernelTerminalV1 { + outputDigest: AgentOperationDigest; + outcomeCode: string; + transcriptRefs: readonly AgentTranscriptReceiptRefV1[]; + /** Present for model operations, including an empty list. Forbidden for MCP. */ + pendingToolUseEntryIds?: readonly string[]; +} +export interface AgentOperationReceiptV1 { + version: 1; + operationId: string; + kind: AgentOperationKind; + fence: Readonly; + planHash: AgentOperationDigest; + authorityHash: AgentOperationDigest; + descriptorDigest: AgentOperationDigest; + payloadDigest: AgentOperationDigest; + actorIdentity: Readonly; + state: AgentOperationState; + acceptedAtMs: number; + executingAtMs?: number; + completedAtMs?: number; + outcome?: AgentOperationOutcomeV1; + transcriptRefs?: readonly AgentTranscriptReceiptRefV1[]; + /** Durable terminal ownership, present only while executing. */ + terminalReservation?: Readonly; + /** Exact actor-terminal replay material, durable before actor settlement. */ + kernelTerminal?: Readonly; + providerRef: AgentOperationProviderRefV1; + errorCode?: + | "reconciliation_unsupported" + | "reconciliation_failed" + | "ambiguous_completion" + | "identity_mismatch" + | "cancellation_ambiguous" + | "timeout_ambiguous" + | "disconnect_ambiguous" + | "invalid_response" + | "operation_failed"; +} + +/** Ephemeral payloads are deliberately outside receipts and ledger records. */ +export interface AgentModelAdapterRequestV1 { + descriptor: AgentModelOperationDescriptorV1; + payload: unknown; +} +export interface AgentMcpAdapterRequestV1 { + descriptor: AgentMcpOperationDescriptorV1; + payload: unknown; +} +export type AgentAdapterReconciliationV1 = + | { status: "settled"; proof: AgentAdapterReconciliationProofV1 } + | { status: "not_started"; proof: AgentAdapterReconciliationProofV1 } + | { + status: "indeterminate"; + reason: + | "reconciliation_unsupported" + | "reconciliation_failed" + | "ambiguous_completion"; + }; +export interface AgentAdapterReconciliationProofV1 { + adapterId: string; + adapterVersion: string; + operationId: string; + kind: AgentOperationKind; + fence: Readonly; + planHash: AgentOperationDigest; + authorityHash: AgentOperationDigest; + descriptorDigest: AgentOperationDigest; + payloadDigest: AgentOperationDigest; + providerRequestRef?: string; + providerResponseRef?: string; +} +export interface AgentAdapterTerminalV1 { + outcome: AgentOperationOutcomeV1; + transcriptRefs: readonly AgentTranscriptReceiptRefV1[]; + providerRequestRef?: string; + providerResponseRef?: string; +} +export interface AgentOperationAdapterV1 { + readonly id: string; + readonly version: string; + execute( + request: Request, + signal: AbortSignal, + ): Promise; + reconcile( + record: AgentOperationReceiptV1, + ): Promise; +} +export type AgentModelOperationAdapterV1 = + AgentOperationAdapterV1; +export type AgentMcpOperationAdapterV1 = + AgentOperationAdapterV1; +export async function unsupportedAgentOperationReconciliation(): Promise { + return { status: "indeterminate", reason: "reconciliation_unsupported" }; +} + +function safeJson(value: unknown): boolean { + let count = 0; + const visit = (v: unknown, depth: number): boolean => { + if ( + ++count > MAX_AGENT_OPERATION_VALUES || + depth > MAX_AGENT_OPERATION_DEPTH + ) + return false; + if (v === null || typeof v === "string" || typeof v === "boolean") + return true; + if (typeof v === "number") return Number.isFinite(v); + if (Array.isArray(v)) { + if (Object.getPrototypeOf(v) !== Array.prototype) return false; + const descriptors = Object.getOwnPropertyDescriptors(v); + const keys = Reflect.ownKeys(descriptors); + if ( + keys.some((key) => typeof key !== "string") || + keys.length !== v.length + 1 || + !Object.hasOwn(descriptors, "length") + ) + return false; + for (let index = 0; index < v.length; index++) { + const descriptor = descriptors[String(index)]; + if ( + !descriptor || + !("value" in descriptor) || + !descriptor.enumerable || + descriptor.value === undefined || + !visit(descriptor.value, depth + 1) + ) + return false; + } + return true; + } + if (!record(v)) return false; + const descriptors = Object.getOwnPropertyDescriptors(v); + const keys = Reflect.ownKeys(descriptors); + if (keys.some((key) => typeof key !== "string")) return false; + return keys.every((key) => { + const descriptor = descriptors[key as string]; + return ( + !!descriptor && + "value" in descriptor && + descriptor.enumerable && + !FORBIDDEN.test(key as string) && + descriptor.value !== undefined && + visit(descriptor.value, depth + 1) + ); + }); + }; + if (!visit(value, 0)) return false; + try { + // Reject Proxy objects whose traps could change values between inspection + // and decoder reconstruction. + const snapshot = structuredClone(value); + return ( + encoder.encode(JSON.stringify(snapshot)).byteLength <= + MAX_AGENT_OPERATION_BYTES + ); + } catch { + return false; + } +} +function ids(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length <= MAX_IDS && + value.every(validId) && + new Set(value).size === value.length + ); +} +function anchor(value: unknown): AgentTranscriptAnchorV1 | undefined { + if ( + !record(value) || + !exact(value, ["throughChangeSeq", "entryIds", "digest"]) || + !time(value.throughChangeSeq) || + !ids(value.entryIds) || + !validDigest(value.digest) + ) + return undefined; + return Object.freeze({ + throughChangeSeq: value.throughChangeSeq, + entryIds: Object.freeze([...value.entryIds]), + digest: value.digest, + }); +} +export function decodeAgentOperationDescriptorV1( + value: unknown, +): AgentOperationDescriptorV1 | undefined { + if (!safeJson(value) || !record(value)) return undefined; + if ( + value.kind === "model" && + exact(value, [ + "version", + "kind", + "stepId", + "transcript", + "modelPolicyHash", + "adapterRequestVersion", + ]) + ) { + const transcript = anchor(value.transcript); + if ( + value.version !== 1 || + !validId(value.stepId) || + !transcript || + !validDigest(value.modelPolicyHash) || + typeof value.adapterRequestVersion !== "string" || + !VERSION.test(value.adapterRequestVersion) + ) + return undefined; + return Object.freeze({ + version: 1, + kind: "model", + stepId: value.stepId, + transcript, + modelPolicyHash: value.modelPolicyHash, + adapterRequestVersion: value.adapterRequestVersion, + }); + } + if ( + value.kind === "mcp" && + exact(value, [ + "version", + "kind", + "toolUseEntryId", + "toolUseId", + "server", + "tool", + "argumentsDigest", + "adapterRequestVersion", + ]) + ) { + if ( + value.version !== 1 || + !validId(value.toolUseEntryId) || + !validId(value.toolUseId) || + !validId(value.server) || + !validId(value.tool) || + !validDigest(value.argumentsDigest) || + typeof value.adapterRequestVersion !== "string" || + !VERSION.test(value.adapterRequestVersion) + ) + return undefined; + return Object.freeze({ + version: 1, + kind: "mcp", + toolUseEntryId: value.toolUseEntryId, + toolUseId: value.toolUseId, + server: value.server, + tool: value.tool, + argumentsDigest: value.argumentsDigest, + adapterRequestVersion: value.adapterRequestVersion, + }); + } + return undefined; +} +export function decodeAgentOperationRequestV1( + value: unknown, +): AgentOperationRequestV1 | undefined { + if ( + !safeJson(value) || + !record(value) || + !exact(value, [ + "version", + "operationId", + "kind", + "fence", + "supervisionEnvelope", + "dispatchGrant", + "descriptor", + "descriptorDigest", + ]) || + value.version !== 1 || + !validId(value.operationId) || + (value.kind !== "model" && value.kind !== "mcp") || + !isAgentTurnFence(value.fence) || + !validDigest(value.descriptorDigest) + ) + return undefined; + const supervisionEnvelope = decodeSignedAgentHostSupervisionEnvelopeV1( + value.supervisionEnvelope, + ); + const dispatchGrant = decodeAgentGatewayDispatchGrant(value.dispatchGrant); + const descriptor = decodeAgentOperationDescriptorV1(value.descriptor); + if ( + !supervisionEnvelope || + !dispatchGrant || + !descriptor || + descriptor.kind !== value.kind + ) + return undefined; + return Object.freeze({ + version: 1, + operationId: value.operationId, + kind: value.kind, + fence: Object.freeze({ ...value.fence }), + supervisionEnvelope, + dispatchGrant, + descriptor, + descriptorDigest: value.descriptorDigest, + }); +} +export function decodeAgentOperationQueryV1( + value: unknown, +): AgentOperationQueryV1 | undefined { + if ( + !safeJson(value) || + !record(value) || + !exact(value, [ + "version", + "operationId", + "kind", + "fence", + "supervisionEnvelope", + "dispatchGrant", + "descriptorDigest", + "payloadDigest", + ]) || + value.version !== 1 || + !validId(value.operationId) || + (value.kind !== "model" && value.kind !== "mcp") || + !isAgentTurnFence(value.fence) || + !validDigest(value.descriptorDigest) || + !validDigest(value.payloadDigest) + ) + return undefined; + const supervisionEnvelope = decodeSignedAgentHostSupervisionEnvelopeV1( + value.supervisionEnvelope, + ); + const dispatchGrant = decodeAgentGatewayDispatchGrant(value.dispatchGrant); + if (!supervisionEnvelope || !dispatchGrant) return undefined; + return Object.freeze({ + ...value, + fence: Object.freeze({ ...value.fence }), + supervisionEnvelope, + dispatchGrant, + }) as AgentOperationQueryV1; +} +function usage(value: unknown): AgentOperationUsageV1 | undefined { + if ( + !record(value) || + !Object.keys(value).every((key) => + [ + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheCreationTokens", + "costUsd", + ].includes(key), + ) || + !Object.entries(value).every(([key, v]) => + key === "costUsd" + ? typeof v === "number" && Number.isFinite(v) && v >= 0 + : Number.isSafeInteger(v) && (v as number) >= 0, + ) + ) + return undefined; + return Object.freeze({ ...value }) as AgentOperationUsageV1; +} +export function decodeAgentTranscriptReceiptRefV1( + value: unknown, +): AgentTranscriptReceiptRefV1 | undefined { + if ( + !safeJson(value) || + !record(value) || + !exact(value, [ + "appendId", + "entryIds", + "firstSeq", + "lastSeq", + "throughChangeSeq", + "requestDigest", + ]) || + !validId(value.appendId) || + !ids(value.entryIds) || + value.entryIds.length === 0 || + !time(value.firstSeq) || + value.firstSeq < 1 || + !time(value.lastSeq) || + value.lastSeq < value.firstSeq || + value.entryIds.length !== value.lastSeq - value.firstSeq + 1 || + !time(value.throughChangeSeq) || + value.throughChangeSeq < 1 || + !validDigest(value.requestDigest) + ) + return undefined; + return Object.freeze({ + appendId: value.appendId, + entryIds: Object.freeze([...value.entryIds]), + firstSeq: value.firstSeq, + lastSeq: value.lastSeq, + throughChangeSeq: value.throughChangeSeq, + requestDigest: value.requestDigest, + }); +} + +function transcriptRef( + value: unknown, +): AgentTranscriptReceiptRefV1 | undefined { + return decodeAgentTranscriptReceiptRefV1(value); +} +function decodeActorIdentity( + kind: AgentOperationKind, + value: unknown, +): AgentOperationActorIdentityV1 | undefined { + if ( + !record(value) || + !exact(value, [ + "supervisorEpoch", + "hostId", + "hostGeneration", + "hostIncarnation", + "transcriptAnchor", + ...(kind === "mcp" ? ["toolUseEntryId"] : []), + ]) + ) + return undefined; + const transcriptAnchor = anchor(value.transcriptAnchor); + if ( + !time(value.supervisorEpoch) || + value.supervisorEpoch < 1 || + !validId(value.hostId) || + !time(value.hostGeneration) || + value.hostGeneration < 1 || + !validId(value.hostIncarnation) || + !transcriptAnchor || + (kind === "mcp" + ? !validId(value.toolUseEntryId) + : value.toolUseEntryId !== undefined) + ) + return undefined; + return Object.freeze({ + supervisorEpoch: value.supervisorEpoch, + hostId: value.hostId, + hostGeneration: value.hostGeneration, + hostIncarnation: value.hostIncarnation, + transcriptAnchor, + ...(kind === "mcp" + ? { toolUseEntryId: value.toolUseEntryId as string } + : {}), + }); +} +function decodeKernelTerminal( + kind: AgentOperationKind, + value: unknown, +): AgentOperationKernelTerminalV1 | undefined { + if (!record(value)) return undefined; + const keys = [ + "outputDigest", + "outcomeCode", + "transcriptRefs", + ...(kind === "model" ? ["pendingToolUseEntryIds"] : []), + ]; + if ( + !exact(value, keys) || + !validDigest(value.outputDigest) || + typeof value.outcomeCode !== "string" || + !/^[a-z][a-z0-9_]{0,63}$/.test(value.outcomeCode) || + !Array.isArray(value.transcriptRefs) || + value.transcriptRefs.length < 1 || + value.transcriptRefs.length > 64 + ) + return undefined; + const refs = value.transcriptRefs.map(transcriptRef); + if ( + refs.some((ref) => !ref) || + refs.some( + (ref, index) => + index > 0 && + (ref!.firstSeq <= refs[index - 1]!.lastSeq || + ref!.throughChangeSeq <= refs[index - 1]!.throughChangeSeq), + ) + ) + return undefined; + const flattened = refs.flatMap((ref) => [...ref!.entryIds]); + if (new Set(flattened).size !== flattened.length) return undefined; + let pending: readonly string[] | undefined; + if (kind === "model") { + if ( + !ids(value.pendingToolUseEntryIds) || + value.pendingToolUseEntryIds.length > 64 + ) + return undefined; + let prior = -1; + for (const entryId of value.pendingToolUseEntryIds) { + const index = flattened.indexOf(entryId); + if (index <= prior) return undefined; + prior = index; + } + pending = Object.freeze([...value.pendingToolUseEntryIds]); + } + return Object.freeze({ + outputDigest: value.outputDigest, + outcomeCode: value.outcomeCode, + transcriptRefs: Object.freeze(refs as AgentTranscriptReceiptRefV1[]), + ...(kind === "model" ? { pendingToolUseEntryIds: pending! } : {}), + }); +} +export function decodeAgentOperationReceiptV1( + value: unknown, +): AgentOperationReceiptV1 | undefined { + const allowed = [ + "version", + "operationId", + "kind", + "fence", + "planHash", + "authorityHash", + "descriptorDigest", + "payloadDigest", + "actorIdentity", + "state", + "acceptedAtMs", + "executingAtMs", + "completedAtMs", + "outcome", + "transcriptRefs", + "terminalReservation", + "kernelTerminal", + "providerRef", + "errorCode", + ]; + if ( + !safeJson(value) || + !record(value) || + !Object.keys(value).every((key) => allowed.includes(key)) || + value.version !== 1 || + !validId(value.operationId) || + (value.kind !== "model" && value.kind !== "mcp") || + !isAgentTurnFence(value.fence) || + !validDigest(value.planHash) || + !validDigest(value.authorityHash) || + !validDigest(value.descriptorDigest) || + !validDigest(value.payloadDigest) || + !["prepared", "executing", "settled", "indeterminate"].includes( + value.state as string, + ) || + !time(value.acceptedAtMs) + ) + return undefined; + if ( + !record(value.providerRef) || + !Object.keys(value.providerRef).every((k) => + ["adapterId", "adapterVersion", "requestId", "responseId"].includes(k), + ) || + !validId(value.providerRef.adapterId) || + typeof value.providerRef.adapterVersion !== "string" || + !VERSION.test(value.providerRef.adapterVersion) || + ![value.providerRef.requestId, value.providerRef.responseId].every( + (v) => v === undefined || (typeof v === "string" && REF.test(v)), + ) + ) + return undefined; + const actorIdentity = decodeActorIdentity( + value.kind as AgentOperationKind, + value.actorIdentity, + ); + if (!actorIdentity) return undefined; + const refs = + value.transcriptRefs === undefined + ? undefined + : Array.isArray(value.transcriptRefs) && + value.transcriptRefs.length <= MAX_IDS + ? value.transcriptRefs.map(transcriptRef) + : undefined; + if (value.transcriptRefs !== undefined && (!refs || refs.some((v) => !v))) + return undefined; + let outcome: AgentOperationOutcomeV1 | undefined; + if (value.outcome !== undefined) { + if ( + !record(value.outcome) || + !Object.keys(value.outcome).every((k) => + ["status", "code", "outputDigest", "usage", "stopReason"].includes(k), + ) || + !["succeeded", "failed", "cancelled"].includes( + value.outcome.status as string, + ) || + !( + value.outcome.code === undefined || + OUTCOME_CODES.includes( + value.outcome.code as AgentOperationOutcomeCodeV1, + ) + ) || + !( + value.outcome.stopReason === undefined || + STOP_REASONS.includes( + value.outcome.stopReason as AgentOperationStopReasonV1, + ) + ) || + !( + value.outcome.outputDigest === undefined || + validDigest(value.outcome.outputDigest) + ) + ) + return undefined; + const decodedUsage = + value.outcome.usage === undefined + ? undefined + : usage(value.outcome.usage); + if (value.outcome.usage !== undefined && !decodedUsage) return undefined; + outcome = Object.freeze({ + ...value.outcome, + ...(decodedUsage === undefined ? {} : { usage: decodedUsage }), + }) as AgentOperationOutcomeV1; + } + const terminalReservation = (() => { + if (value.terminalReservation === undefined) return undefined; + if ( + !record(value.terminalReservation) || + !exact(value.terminalReservation, [ + "reservationId", + "reason", + "reservedAtMs", + ]) || + typeof value.terminalReservation.reservationId !== "string" || + !/^reservation:[a-f0-9]{64}$/.test( + value.terminalReservation.reservationId, + ) || + !RESERVATION_REASONS.includes( + value.terminalReservation + .reason as (typeof RESERVATION_REASONS)[number], + ) || + !time(value.terminalReservation.reservedAtMs) + ) + return null; + return Object.freeze({ + reservationId: value.terminalReservation.reservationId, + reason: value.terminalReservation + .reason as (typeof RESERVATION_REASONS)[number], + reservedAtMs: value.terminalReservation.reservedAtMs as number, + }); + })(); + if (terminalReservation === null) return undefined; + const kernelTerminal = decodeKernelTerminal( + value.kind as AgentOperationKind, + value.kernelTerminal, + ); + if (value.kernelTerminal !== undefined && !kernelTerminal) return undefined; + if ( + kernelTerminal && + (JSON.stringify(kernelTerminal.transcriptRefs) !== JSON.stringify(refs) || + (outcome?.outputDigest !== undefined && + outcome.outputDigest !== kernelTerminal.outputDigest) || + (outcome?.code !== undefined && + outcome.code !== kernelTerminal.outcomeCode)) + ) + return undefined; + const state = value.state as AgentOperationState; + if ( + (state === "prepared" && + (value.executingAtMs !== undefined || + value.completedAtMs !== undefined || + outcome || + value.transcriptRefs !== undefined || + terminalReservation !== undefined || + kernelTerminal !== undefined || + value.providerRef.requestId !== undefined || + value.providerRef.responseId !== undefined || + value.errorCode !== undefined)) || + (state === "executing" && + (!time(value.executingAtMs) || + value.completedAtMs !== undefined || + (terminalReservation !== undefined && + terminalReservation.reservedAtMs < value.executingAtMs) || + outcome || + value.transcriptRefs !== undefined || + kernelTerminal !== undefined || + value.providerRef.requestId !== undefined || + value.providerRef.responseId !== undefined || + value.errorCode !== undefined)) || + ((state === "settled" || state === "indeterminate") && + (!time(value.executingAtMs) || + !time(value.completedAtMs) || + terminalReservation !== undefined)) || + (time(value.executingAtMs) && value.executingAtMs < value.acceptedAtMs) || + (time(value.completedAtMs) && + value.completedAtMs < + (time(value.executingAtMs) + ? value.executingAtMs + : value.acceptedAtMs)) || + (state === "settled" && + (!outcome || !kernelTerminal || value.errorCode !== undefined)) || + (state === "indeterminate" && + (outcome !== undefined || + !kernelTerminal || + !ERROR_CODES.includes( + value.errorCode as (typeof ERROR_CODES)[number], + ) || + kernelTerminal.outcomeCode !== value.errorCode)) + ) + return undefined; + return Object.freeze({ + ...value, + fence: Object.freeze({ ...value.fence }), + actorIdentity, + ...(outcome === undefined ? {} : { outcome }), + ...(refs === undefined ? {} : { transcriptRefs: Object.freeze(refs) }), + ...(terminalReservation === undefined ? {} : { terminalReservation }), + ...(kernelTerminal === undefined ? {} : { kernelTerminal }), + }) as AgentOperationReceiptV1; +} + +function canonicalDescriptor(v: AgentOperationDescriptorV1): string { + return v.kind === "model" + ? JSON.stringify({ + version: 1, + kind: "model", + stepId: v.stepId, + transcript: { + throughChangeSeq: v.transcript.throughChangeSeq, + entryIds: v.transcript.entryIds, + digest: v.transcript.digest, + }, + modelPolicyHash: v.modelPolicyHash, + adapterRequestVersion: v.adapterRequestVersion, + }) + : JSON.stringify({ + version: 1, + kind: "mcp", + toolUseEntryId: v.toolUseEntryId, + toolUseId: v.toolUseId, + server: v.server, + tool: v.tool, + argumentsDigest: v.argumentsDigest, + adapterRequestVersion: v.adapterRequestVersion, + }); +} +export function serializeAgentOperationDescriptorV1( + value: AgentOperationDescriptorV1, +): Uint8Array { + const decoded = decodeAgentOperationDescriptorV1(value); + if (!decoded) throw new TypeError("invalid descriptor"); + return encoder.encode(canonicalDescriptor(decoded)); +} +function canonicalFence(fence: Readonly) { + return { + sessionId: fence.sessionId, + runId: fence.runId, + turnId: fence.turnId, + generation: fence.generation, + }; +} +function canonicalEnvelope(envelope: SignedAgentHostSupervisionEnvelopeV1) { + return { + version: envelope.version, + algorithm: envelope.algorithm, + domain: envelope.domain, + authorityBytes: envelope.authorityBytes, + signature: envelope.signature, + }; +} +export function serializeAgentOperationRequestV1( + value: AgentOperationRequestV1, +): Uint8Array { + const decoded = decodeAgentOperationRequestV1(value); + if (!decoded) throw new TypeError("invalid Agent operation request"); + return encoder.encode( + JSON.stringify({ + version: 1, + operationId: decoded.operationId, + kind: decoded.kind, + fence: canonicalFence(decoded.fence), + supervisionEnvelope: canonicalEnvelope(decoded.supervisionEnvelope), + dispatchGrant: decoded.dispatchGrant, + descriptor: JSON.parse(canonicalDescriptor(decoded.descriptor)), + descriptorDigest: decoded.descriptorDigest, + }), + ); +} +export function serializeAgentOperationQueryV1( + value: AgentOperationQueryV1, +): Uint8Array { + const decoded = decodeAgentOperationQueryV1(value); + if (!decoded) throw new TypeError("invalid Agent operation query"); + return encoder.encode( + JSON.stringify({ + version: 1, + operationId: decoded.operationId, + kind: decoded.kind, + fence: canonicalFence(decoded.fence), + supervisionEnvelope: canonicalEnvelope(decoded.supervisionEnvelope), + dispatchGrant: decoded.dispatchGrant, + descriptorDigest: decoded.descriptorDigest, + payloadDigest: decoded.payloadDigest, + }), + ); +} +export function serializeAgentOperationReceiptV1( + value: AgentOperationReceiptV1, +): Uint8Array { + const v = decodeAgentOperationReceiptV1(value); + if (!v) throw new TypeError("invalid receipt"); + const outcome = v.outcome && { + status: v.outcome.status, + ...(v.outcome.code === undefined ? {} : { code: v.outcome.code }), + ...(v.outcome.outputDigest === undefined + ? {} + : { outputDigest: v.outcome.outputDigest }), + ...(v.outcome.usage === undefined + ? {} + : { + usage: { + ...(v.outcome.usage.inputTokens === undefined + ? {} + : { inputTokens: v.outcome.usage.inputTokens }), + ...(v.outcome.usage.outputTokens === undefined + ? {} + : { outputTokens: v.outcome.usage.outputTokens }), + ...(v.outcome.usage.cacheReadTokens === undefined + ? {} + : { cacheReadTokens: v.outcome.usage.cacheReadTokens }), + ...(v.outcome.usage.cacheCreationTokens === undefined + ? {} + : { cacheCreationTokens: v.outcome.usage.cacheCreationTokens }), + ...(v.outcome.usage.costUsd === undefined + ? {} + : { costUsd: v.outcome.usage.costUsd }), + }, + }), + ...(v.outcome.stopReason === undefined + ? {} + : { stopReason: v.outcome.stopReason }), + }; + return encoder.encode( + JSON.stringify({ + version: 1, + operationId: v.operationId, + kind: v.kind, + fence: canonicalFence(v.fence), + planHash: v.planHash, + authorityHash: v.authorityHash, + descriptorDigest: v.descriptorDigest, + payloadDigest: v.payloadDigest, + actorIdentity: { + supervisorEpoch: v.actorIdentity.supervisorEpoch, + hostId: v.actorIdentity.hostId, + hostGeneration: v.actorIdentity.hostGeneration, + hostIncarnation: v.actorIdentity.hostIncarnation, + transcriptAnchor: v.actorIdentity.transcriptAnchor, + ...(v.kind === "mcp" + ? { toolUseEntryId: v.actorIdentity.toolUseEntryId } + : {}), + }, + state: v.state, + acceptedAtMs: v.acceptedAtMs, + ...(v.executingAtMs === undefined + ? {} + : { executingAtMs: v.executingAtMs }), + ...(v.completedAtMs === undefined + ? {} + : { completedAtMs: v.completedAtMs }), + ...(outcome === undefined ? {} : { outcome }), + ...(v.transcriptRefs === undefined + ? {} + : { + transcriptRefs: v.transcriptRefs.map((ref) => ({ + appendId: ref.appendId, + entryIds: ref.entryIds, + firstSeq: ref.firstSeq, + lastSeq: ref.lastSeq, + throughChangeSeq: ref.throughChangeSeq, + requestDigest: ref.requestDigest, + })), + }), + ...(v.terminalReservation === undefined + ? {} + : { + terminalReservation: { + reservationId: v.terminalReservation.reservationId, + reason: v.terminalReservation.reason, + reservedAtMs: v.terminalReservation.reservedAtMs, + }, + }), + ...(v.kernelTerminal === undefined + ? {} + : { + kernelTerminal: { + outputDigest: v.kernelTerminal.outputDigest, + outcomeCode: v.kernelTerminal.outcomeCode, + transcriptRefs: v.kernelTerminal.transcriptRefs, + ...(v.kind === "model" + ? { + pendingToolUseEntryIds: + v.kernelTerminal.pendingToolUseEntryIds, + } + : {}), + }, + }), + providerRef: { + adapterId: v.providerRef.adapterId, + adapterVersion: v.providerRef.adapterVersion, + ...(v.providerRef.requestId === undefined + ? {} + : { requestId: v.providerRef.requestId }), + ...(v.providerRef.responseId === undefined + ? {} + : { responseId: v.providerRef.responseId }), + }, + ...(v.errorCode === undefined ? {} : { errorCode: v.errorCode }), + }), + ); +} +async function hash( + domain: string, + bytes: Uint8Array, +): Promise { + const prefix = encoder.encode(`${domain}\0`); + const all = new Uint8Array(prefix.length + bytes.length); + all.set(prefix); + all.set(bytes, prefix.length); + const result = new Uint8Array(await crypto.subtle.digest("SHA-256", all)); + return `sha256:${[...result].map((v) => v.toString(16).padStart(2, "0")).join("")}`; +} +/** Gateway recomputation helpers. Submitted digests are never trusted. */ +export const hashAgentOperationDescriptorV1 = (v: AgentOperationDescriptorV1) => + hash( + AGENT_OPERATION_DESCRIPTOR_DIGEST_DOMAIN, + serializeAgentOperationDescriptorV1(v), + ); +export const hashAgentModelPayloadV1 = (v: Uint8Array) => + hash(AGENT_MODEL_PAYLOAD_DIGEST_DOMAIN, v); +export const hashAgentMcpPayloadV1 = (v: Uint8Array) => + hash(AGENT_MCP_PAYLOAD_DIGEST_DOMAIN, v); +export const hashAgentMcpArgumentsV1 = (v: Uint8Array) => + hash(AGENT_MCP_ARGUMENTS_DIGEST_DOMAIN, v); +export const hashAgentOperationReceiptV1 = (v: AgentOperationReceiptV1) => + hash( + AGENT_OPERATION_RECEIPT_DIGEST_DOMAIN, + serializeAgentOperationReceiptV1(v), + ); diff --git a/packages/core/protocol/src/index.ts b/packages/core/protocol/src/index.ts index 052aeac7c6..dc842cb1be 100644 --- a/packages/core/protocol/src/index.ts +++ b/packages/core/protocol/src/index.ts @@ -7,6 +7,7 @@ * durably, so a reply never renders twice * - `./runner` — the run-host wire contract (`RunHostSpec`, host/client * messages, NDJSON framing): what "bring your own runner" means + * - `./agent-host` — the local control-plane turn contract * - `./executor` — the tool/workspace-only execution wire contract * - `./session` — the client↔server session contract (`TranscriptEntry`, * asks, usage, core WebSocket frames): "bring your own UI" @@ -28,3 +29,7 @@ export * from "./todo-plan"; export * from "./tool-presentation"; export * from "./identity"; export * from "./executor"; +export * from "./agent-host-fence"; +export * from "./agent-host"; +export * from "./agent-host-supervision"; +export * from "./agent-operation"; diff --git a/scripts/check-module-side-effects.ts b/scripts/check-module-side-effects.ts index 9c2114b705..96f41b50e6 100644 --- a/scripts/check-module-side-effects.ts +++ b/scripts/check-module-side-effects.ts @@ -71,7 +71,7 @@ export const EXECUTABLE_ENTRYPOINT_EXEMPTIONS = new Set([ * helpers are excluded: they are not on any live import chain. */ export function serverModules(root = REPO_ROOT): string[] { const glob = new Bun.Glob( - "packages/core/opensession-server/src/{server,executor,runner-host}/**/*.ts", + "packages/core/opensession-server/src/{server,executor,runner-host,agent-host}/**/*.ts", ); return [...glob.scanSync({ cwd: root }), "packages/core/opensession-server/src/session-kernel-worker.ts"]