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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions deploy/agent-host-service.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
5 changes: 5 additions & 0 deletions deploy/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions deploy/install-agent-host-topology.sh
Original file line number Diff line number Diff line change
@@ -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 <stable-release-directory> <bun-binary>" >&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"
Original file line number Diff line number Diff line change
@@ -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
85 changes: 85 additions & 0 deletions docs/agent-host-deployment.md
Original file line number Diff line number Diff line change
@@ -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@<generation>.socket` creates the root-owned
`/run/opensession/agent-host-<generation>.sock`. Its mode is `0660`, owner is
root, and its group is exactly `opensession-gateway`.
- `opensession-agent-host@<generation>.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/<generation>` 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/<generation>/ledger-keyring.json`
- `/etc/opensession/credentials/agent-host/<generation>/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 <commit-sha>
```

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.
97 changes: 97 additions & 0 deletions docs/canary-qualification.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions docs/executor-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading