refactor: move native hook relay registry to SQLite (#109289)

* refactor: store native hook relays in SQLite

* chore: satisfy relay store CI checks
This commit is contained in:
Peter Steinberger
2026-07-16 12:14:06 -07:00
committed by GitHub
parent 3f89aae98d
commit 6c39e713d4
13 changed files with 1031 additions and 289 deletions
-1
View File
@@ -2271,7 +2271,6 @@ Add a repo check that fails new runtime writes to legacy state paths:
- Discord `model-picker-preferences.json`
- Discord `command-deploy-cache.json`
- sandbox registry shard JSON files
- native hook relay `/tmp` bridge JSON files
- `plugin-state/state.sqlite`
- ad-hoc `openclaw-state.sqlite` runtime sidecars
- `tasks/runs.sqlite`
@@ -108,6 +108,7 @@ const legacyStorePatterns = [
/\bplugin-state\/state\.sqlite\b/u,
/\btasks\/(?:runs\.sqlite|flows\/registry\.sqlite)\b/u,
/\bopenclaw-state\.sqlite\b/u,
/\bopenclaw-native-hook-relays\b/u,
];
const allowedRuntimeMigrationPaths = [
@@ -16,6 +16,7 @@ const removedAsyncTransactionNames = new Set([
]);
const synchronousTransactionCallbackIndexes = new Map([
["runOpenClawAgentWriteTransaction", 0],
["runOpenClawStateWriteTransaction", 0],
["runSqliteImmediateTransactionSync", 1],
]);
@@ -0,0 +1,350 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import {
deleteNativeHookRelayBridgeRecordIfOwned,
pruneNativeHookRelayBridgeRecords,
readNativeHookRelayBridgeRecord,
renewOrRestoreNativeHookRelayBridgeRecord,
type NativeHookRelayBridgeRecord,
writeNativeHookRelayBridgeRecord,
} from "./native-hook-relay-store.js";
let testRoot = "";
let primaryStateDbPath = "";
let secondaryStateDbPath = "";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
beforeEach(() => {
testRoot = tempDirs.make("openclaw-native-hook-relay-store-");
primaryStateDbPath = path.join(testRoot, "primary.sqlite");
secondaryStateDbPath = path.join(testRoot, "secondary.sqlite");
});
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
function bridgeRecord(
relayId: string,
overrides: Partial<NativeHookRelayBridgeRecord> = {},
): NativeHookRelayBridgeRecord {
return {
relayId,
pid: 100,
hostname: "127.0.0.1",
port: 18_789,
token: "test-token-placeholder",
expiresAtMs: 20_000,
...overrides,
};
}
describe("native hook relay store", () => {
it("upserts and reads bridge records", () => {
const first = bridgeRecord("relay-upsert");
const replacement = bridgeRecord("relay-upsert", {
pid: 101,
port: 18_790,
token: "test-auth-token",
expiresAtMs: 30_000,
});
writeNativeHookRelayBridgeRecord({
record: first,
updatedAtMs: 1_000,
stateDbPath: primaryStateDbPath,
});
expect(
readNativeHookRelayBridgeRecord({
relayId: first.relayId,
stateDbPath: primaryStateDbPath,
}),
).toStrictEqual(first);
writeNativeHookRelayBridgeRecord({
record: replacement,
updatedAtMs: 2_000,
stateDbPath: primaryStateDbPath,
});
expect(
readNativeHookRelayBridgeRecord({
relayId: replacement.relayId,
stateDbPath: primaryStateDbPath,
}),
).toStrictEqual(replacement);
});
it("requires matching token and pid to renew or delete a bridge", () => {
const record = bridgeRecord("relay-owned");
writeNativeHookRelayBridgeRecord({
record,
updatedAtMs: 1_000,
stateDbPath: primaryStateDbPath,
});
expect(
renewOrRestoreNativeHookRelayBridgeRecord({
record: { ...record, pid: record.pid + 1, expiresAtMs: 30_000 },
stateDbPath: primaryStateDbPath,
}),
).toBe(false);
expect(
renewOrRestoreNativeHookRelayBridgeRecord({
record: { ...record, token: "decoy-token", expiresAtMs: 30_000 },
stateDbPath: primaryStateDbPath,
}),
).toBe(false);
expect(
renewOrRestoreNativeHookRelayBridgeRecord({
record: { ...record, expiresAtMs: 30_000 },
updatedAtMs: 2_000,
stateDbPath: primaryStateDbPath,
}),
).toBe(true);
expect(
readNativeHookRelayBridgeRecord({
relayId: record.relayId,
stateDbPath: primaryStateDbPath,
}),
).toStrictEqual({ ...record, expiresAtMs: 30_000 });
expect(
deleteNativeHookRelayBridgeRecordIfOwned({
...record,
pid: record.pid + 1,
stateDbPath: primaryStateDbPath,
}),
).toBe(false);
expect(
deleteNativeHookRelayBridgeRecordIfOwned({
...record,
token: "decoy-token",
stateDbPath: primaryStateDbPath,
}),
).toBe(false);
expect(
deleteNativeHookRelayBridgeRecordIfOwned({
...record,
stateDbPath: primaryStateDbPath,
}),
).toBe(true);
expect(
readNativeHookRelayBridgeRecord({
relayId: record.relayId,
stateDbPath: primaryStateDbPath,
}),
).toBeUndefined();
});
it("restores a missing record without overwriting another owner", () => {
const record = bridgeRecord("relay-restored");
expect(
renewOrRestoreNativeHookRelayBridgeRecord({
record,
updatedAtMs: 1_000,
stateDbPath: primaryStateDbPath,
}),
).toBe(true);
expect(
readNativeHookRelayBridgeRecord({
relayId: record.relayId,
stateDbPath: primaryStateDbPath,
}),
).toStrictEqual(record);
const otherOwner = bridgeRecord(record.relayId, {
pid: record.pid + 1,
token: "test-auth-token",
});
writeNativeHookRelayBridgeRecord({
record: otherOwner,
updatedAtMs: 2_000,
stateDbPath: primaryStateDbPath,
});
expect(
renewOrRestoreNativeHookRelayBridgeRecord({
record,
updatedAtMs: 3_000,
stateDbPath: primaryStateDbPath,
}),
).toBe(false);
expect(
readNativeHookRelayBridgeRecord({
relayId: record.relayId,
stateDbPath: primaryStateDbPath,
}),
).toStrictEqual(otherOwner);
});
it("does not let an old owner delete its replacement", () => {
const oldOwner = bridgeRecord("relay-replaced", {
pid: 100,
token: "secret-token",
});
const replacement = bridgeRecord("relay-replaced", {
pid: 101,
port: 18_790,
token: "test-auth-token",
expiresAtMs: 30_000,
});
writeNativeHookRelayBridgeRecord({
record: oldOwner,
updatedAtMs: 1_000,
stateDbPath: primaryStateDbPath,
});
writeNativeHookRelayBridgeRecord({
record: replacement,
updatedAtMs: 2_000,
stateDbPath: primaryStateDbPath,
});
expect(
deleteNativeHookRelayBridgeRecordIfOwned({
...oldOwner,
stateDbPath: primaryStateDbPath,
}),
).toBe(false);
expect(
readNativeHookRelayBridgeRecord({
relayId: replacement.relayId,
stateDbPath: primaryStateDbPath,
}),
).toStrictEqual(replacement);
});
it("prunes expired and dead bridges while preserving live and unknown pids", () => {
const expired = bridgeRecord("relay-expired", { pid: 200, expiresAtMs: 9_999 });
const dead = bridgeRecord("relay-dead", { pid: 201 });
const live = bridgeRecord("relay-live", { pid: 202 });
const unknown = bridgeRecord("relay-unknown", { pid: 203 });
for (const [index, record] of [expired, dead, live, unknown].entries()) {
writeNativeHookRelayBridgeRecord({
record,
updatedAtMs: 1_000 + index,
stateDbPath: primaryStateDbPath,
});
}
const isPidDead = vi.fn((pid: number) => pid === dead.pid);
const pruned = pruneNativeHookRelayBridgeRecords({
currentPid: 100,
isPidDead,
nowMs: 10_000,
stateDbPath: primaryStateDbPath,
});
expect(pruned).toHaveLength(2);
expect(pruned).toEqual(
expect.arrayContaining([
{ relayId: expired.relayId, pid: expired.pid, reason: "expired" },
{ relayId: dead.relayId, pid: dead.pid, reason: "dead-pid" },
]),
);
expect(new Set(isPidDead.mock.calls.map(([pid]) => pid))).toStrictEqual(
new Set([dead.pid, live.pid, unknown.pid]),
);
expect(
readNativeHookRelayBridgeRecord({
relayId: expired.relayId,
stateDbPath: primaryStateDbPath,
}),
).toBeUndefined();
expect(
readNativeHookRelayBridgeRecord({
relayId: dead.relayId,
stateDbPath: primaryStateDbPath,
}),
).toBeUndefined();
expect(
readNativeHookRelayBridgeRecord({
relayId: live.relayId,
stateDbPath: primaryStateDbPath,
}),
).toStrictEqual(live);
expect(
readNativeHookRelayBridgeRecord({
relayId: unknown.relayId,
stateDbPath: primaryStateDbPath,
}),
).toStrictEqual(unknown);
});
it("preserves a replacement published during dead-pid planning", () => {
const stale = bridgeRecord("relay-prune-race", {
pid: 201,
token: "secret-token",
});
const replacement = bridgeRecord("relay-prune-race", {
pid: 202,
port: 18_790,
token: "test-auth-token",
expiresAtMs: 30_000,
});
writeNativeHookRelayBridgeRecord({
record: stale,
updatedAtMs: 1_000,
stateDbPath: primaryStateDbPath,
});
const pruned = pruneNativeHookRelayBridgeRecords({
currentPid: 100,
isPidDead: (pid) => {
expect(pid).toBe(stale.pid);
writeNativeHookRelayBridgeRecord({
record: replacement,
updatedAtMs: 2_000,
stateDbPath: primaryStateDbPath,
});
return true;
},
nowMs: 10_000,
stateDbPath: primaryStateDbPath,
});
expect(pruned).toStrictEqual([]);
expect(
readNativeHookRelayBridgeRecord({
relayId: replacement.relayId,
stateDbPath: primaryStateDbPath,
}),
).toStrictEqual(replacement);
});
it("isolates records by the exact state database path", () => {
const primary = bridgeRecord("relay-isolated", {
pid: 100,
token: "config-token",
});
const secondary = bridgeRecord("relay-isolated", {
pid: 200,
port: 18_790,
token: "gateway-token",
});
writeNativeHookRelayBridgeRecord({
record: primary,
stateDbPath: primaryStateDbPath,
});
writeNativeHookRelayBridgeRecord({
record: secondary,
stateDbPath: secondaryStateDbPath,
});
expect(fs.existsSync(primaryStateDbPath)).toBe(true);
expect(fs.existsSync(secondaryStateDbPath)).toBe(true);
expect(
readNativeHookRelayBridgeRecord({
relayId: primary.relayId,
stateDbPath: primaryStateDbPath,
}),
).toStrictEqual(primary);
expect(
readNativeHookRelayBridgeRecord({
relayId: secondary.relayId,
stateDbPath: secondaryStateDbPath,
}),
).toStrictEqual(secondary);
});
});
@@ -0,0 +1,329 @@
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import { withOpenClawStateDatabaseReadOnly } from "../../state/openclaw-state-db-readonly.js";
import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../../state/openclaw-state-db.js";
export type NativeHookRelayBridgeRecord = {
relayId: string;
pid: number;
hostname: "127.0.0.1";
port: number;
token: string;
expiresAtMs: number;
};
type NativeHookRelayBridgePruneResult = {
relayId: string;
pid: number;
reason: "dead-pid" | "expired";
};
type NativeHookRelayBridgeDatabase = Pick<OpenClawStateKyselyDatabase, "native_hook_relay_bridges">;
type NativeHookRelayBridgeRow = OpenClawStateKyselyDatabase["native_hook_relay_bridges"];
type NativeHookRelayBridgeSnapshot = {
record: NativeHookRelayBridgeRecord;
updatedAtMs: number;
};
type NativeHookRelayBridgePruneCandidate = {
snapshot: NativeHookRelayBridgeSnapshot;
reason: NativeHookRelayBridgePruneResult["reason"];
};
type NativeHookRelayBridgeStoreOptions = {
stateDbPath?: string;
};
function readNativeHookRelayBridgeSnapshot(
row: NativeHookRelayBridgeRow | undefined,
): NativeHookRelayBridgeSnapshot | undefined {
if (
!row ||
typeof row.relay_id !== "string" ||
row.relay_id.length === 0 ||
!Number.isSafeInteger(row.pid) ||
row.hostname !== "127.0.0.1" ||
!Number.isSafeInteger(row.port) ||
row.port <= 0 ||
row.port > 65_535 ||
typeof row.token !== "string" ||
row.token.length === 0 ||
!Number.isSafeInteger(row.expires_at_ms) ||
!Number.isSafeInteger(row.updated_at_ms)
) {
return undefined;
}
const { token } = row;
return {
record: {
relayId: row.relay_id,
pid: row.pid,
hostname: row.hostname,
port: row.port,
token,
expiresAtMs: row.expires_at_ms,
},
updatedAtMs: row.updated_at_ms,
};
}
function readNativeHookRelayBridgeSnapshotFromDatabase(params: {
database: { db: DatabaseSync };
relayId: string;
}): NativeHookRelayBridgeSnapshot | undefined {
const db = getNodeSqliteKysely<NativeHookRelayBridgeDatabase>(params.database.db);
return readNativeHookRelayBridgeSnapshot(
executeSqliteQueryTakeFirstSync(
params.database.db,
db.selectFrom("native_hook_relay_bridges").selectAll().where("relay_id", "=", params.relayId),
),
);
}
function sameNativeHookRelayBridgeSnapshot(
left: NativeHookRelayBridgeSnapshot,
right: NativeHookRelayBridgeSnapshot,
): boolean {
return (
left.updatedAtMs === right.updatedAtMs &&
left.record.relayId === right.record.relayId &&
left.record.pid === right.record.pid &&
left.record.hostname === right.record.hostname &&
left.record.port === right.record.port &&
left.record.token === right.record.token &&
left.record.expiresAtMs === right.record.expiresAtMs
);
}
export function readNativeHookRelayBridgeRecord(
params: { relayId: string } & NativeHookRelayBridgeStoreOptions,
): NativeHookRelayBridgeRecord | undefined {
return withOpenClawStateDatabaseReadOnly(
(database) =>
readNativeHookRelayBridgeSnapshotFromDatabase({
database,
relayId: params.relayId,
})?.record,
{ path: params.stateDbPath },
);
}
export function writeNativeHookRelayBridgeRecord(
params: {
record: NativeHookRelayBridgeRecord;
updatedAtMs?: number;
} & NativeHookRelayBridgeStoreOptions,
): void {
const updatedAtMs = params.updatedAtMs ?? Date.now();
const record = params.record;
const { token } = record;
runOpenClawStateWriteTransaction(
(database) => {
const db = getNodeSqliteKysely<NativeHookRelayBridgeDatabase>(database.db);
executeSqliteQuerySync(
database.db,
db
.insertInto("native_hook_relay_bridges")
.values({
relay_id: record.relayId,
pid: record.pid,
hostname: record.hostname,
port: record.port,
token,
expires_at_ms: record.expiresAtMs,
updated_at_ms: updatedAtMs,
})
.onConflict((conflict) =>
conflict.column("relay_id").doUpdateSet({
pid: record.pid,
hostname: record.hostname,
port: record.port,
token,
expires_at_ms: record.expiresAtMs,
updated_at_ms: updatedAtMs,
}),
),
);
},
{ path: params.stateDbPath },
);
}
export function renewOrRestoreNativeHookRelayBridgeRecord(
params: {
record: NativeHookRelayBridgeRecord;
updatedAtMs?: number;
} & NativeHookRelayBridgeStoreOptions,
): boolean {
const { record } = params;
const { token } = record;
const updatedAtMs = params.updatedAtMs ?? Date.now();
return runOpenClawStateWriteTransaction(
(database) => {
const db = getNodeSqliteKysely<NativeHookRelayBridgeDatabase>(database.db);
const current = readNativeHookRelayBridgeSnapshotFromDatabase({
database,
relayId: record.relayId,
});
if (!current) {
const result = executeSqliteQuerySync(
database.db,
db
.insertInto("native_hook_relay_bridges")
.values({
relay_id: record.relayId,
pid: record.pid,
hostname: record.hostname,
port: record.port,
token,
expires_at_ms: record.expiresAtMs,
updated_at_ms: updatedAtMs,
})
.onConflict((conflict) => conflict.column("relay_id").doNothing()),
);
return result.numAffectedRows === 1n;
}
if (current.record.pid !== record.pid || current.record.token !== token) {
return false;
}
const result = executeSqliteQuerySync(
database.db,
db
.updateTable("native_hook_relay_bridges")
.set({
hostname: record.hostname,
port: record.port,
expires_at_ms: record.expiresAtMs,
updated_at_ms: updatedAtMs,
})
.where("relay_id", "=", record.relayId)
.where("pid", "=", record.pid)
.where("token", "=", token)
.where("updated_at_ms", "=", current.updatedAtMs),
);
return result.numAffectedRows === 1n;
},
{ path: params.stateDbPath },
);
}
export function deleteNativeHookRelayBridgeRecordIfOwned(params: {
relayId: string;
pid: number;
token: string;
stateDbPath?: string;
}): boolean {
return runOpenClawStateWriteTransaction(
(database) => {
const current = readNativeHookRelayBridgeSnapshotFromDatabase({
database,
relayId: params.relayId,
});
if (!current || current.record.pid !== params.pid || current.record.token !== params.token) {
return false;
}
const db = getNodeSqliteKysely<NativeHookRelayBridgeDatabase>(database.db);
const result = executeSqliteQuerySync(
database.db,
db
.deleteFrom("native_hook_relay_bridges")
.where("relay_id", "=", params.relayId)
.where("pid", "=", params.pid)
.where("token", "=", params.token)
.where("updated_at_ms", "=", current.updatedAtMs),
);
return result.numAffectedRows === 1n;
},
{ path: params.stateDbPath },
);
}
export function pruneNativeHookRelayBridgeRecords(params: {
currentPid: number;
isPidDead: (pid: number) => boolean;
nowMs?: number;
stateDbPath?: string;
}): NativeHookRelayBridgePruneResult[] {
const nowMs = params.nowMs ?? Date.now();
const database = openOpenClawStateDatabase({ path: params.stateDbPath });
const db = getNodeSqliteKysely<NativeHookRelayBridgeDatabase>(database.db);
const snapshots = executeSqliteQuerySync(
database.db,
db.selectFrom("native_hook_relay_bridges").selectAll(),
).rows.flatMap((row) => {
const snapshot = readNativeHookRelayBridgeSnapshot(row);
return snapshot ? [snapshot] : [];
});
const candidates: NativeHookRelayBridgePruneCandidate[] = [];
for (const snapshot of snapshots) {
if (nowMs > snapshot.record.expiresAtMs) {
candidates.push({ snapshot, reason: "expired" });
continue;
}
if (snapshot.record.pid !== params.currentPid && params.isPidDead(snapshot.record.pid)) {
candidates.push({ snapshot, reason: "dead-pid" });
}
}
if (candidates.length === 0) {
return [];
}
return runOpenClawStateWriteTransaction(
(writeDatabase) => {
const writeDb = getNodeSqliteKysely<NativeHookRelayBridgeDatabase>(writeDatabase.db);
const pruned: NativeHookRelayBridgePruneResult[] = [];
for (const candidate of candidates) {
const current = readNativeHookRelayBridgeSnapshotFromDatabase({
database: writeDatabase,
relayId: candidate.snapshot.record.relayId,
});
if (
!current ||
!sameNativeHookRelayBridgeSnapshot(current, candidate.snapshot) ||
(candidate.reason === "expired" && nowMs <= current.record.expiresAtMs)
) {
continue;
}
const result = executeSqliteQuerySync(
writeDatabase.db,
writeDb
.deleteFrom("native_hook_relay_bridges")
.where("relay_id", "=", current.record.relayId)
.where("token", "=", current.record.token)
.where("updated_at_ms", "=", current.updatedAtMs),
);
if (result.numAffectedRows === 1n) {
pruned.push({
relayId: current.record.relayId,
pid: current.record.pid,
reason: candidate.reason,
});
}
}
return pruned;
},
{ path: params.stateDbPath },
);
}
export function clearNativeHookRelayBridgeRecordsForTests(
options: NativeHookRelayBridgeStoreOptions = {},
): void {
runOpenClawStateWriteTransaction(
(database) => {
const db = getNodeSqliteKysely<NativeHookRelayBridgeDatabase>(database.db);
executeSqliteQuerySync(database.db, db.deleteFrom("native_hook_relay_bridges"));
},
{ path: options.stateDbPath },
);
}
import type { DatabaseSync } from "node:sqlite";
+107 -94
View File
@@ -1,6 +1,5 @@
// Covers native hook relay registration, bridge invocation, and approval state.
import { randomUUID } from "node:crypto";
import { rmSync, statSync, writeFileSync } from "node:fs";
import fs from "node:fs/promises";
import { createServer, request as httpRequest } from "node:http";
import { tmpdir } from "node:os";
@@ -16,6 +15,13 @@ import { createMockPluginRegistry } from "../../plugins/hooks.test-fixtures.js";
import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import {
deleteNativeHookRelayBridgeRecordIfOwned,
readNativeHookRelayBridgeRecord,
writeNativeHookRelayBridgeRecord,
type NativeHookRelayBridgeRecord,
} from "./native-hook-relay-store.js";
import {
testing,
buildNativeHookRelayCommand,
@@ -78,13 +84,16 @@ function getOnlyNativeHookRelayInvocation() {
async function waitForNativeHookRelayBridgeRecord(
relayId: string,
): Promise<Record<string, unknown>> {
let record: Record<string, unknown> | undefined;
): Promise<NativeHookRelayBridgeRecord> {
let record: NativeHookRelayBridgeRecord | undefined;
await vi.waitFor(() => {
record = testing.getNativeHookRelayBridgeRecordForTests(relayId);
expect(isRecord(record) ? record.relayId : undefined).toBe(relayId);
record = readNativeHookRelayBridgeRecord({ relayId });
expect(record?.relayId).toBe(relayId);
});
return record as Record<string, unknown>;
if (!record) {
throw new Error(`Expected native hook relay bridge record for ${relayId}`);
}
return record;
}
async function writeForeignNativeHookRelayBridgeRecordForTests(
@@ -94,33 +103,29 @@ async function writeForeignNativeHookRelayBridgeRecordForTests(
expiresAtMs: number;
},
): Promise<string> {
// Foreign bridge records simulate another process owning the relay server,
// without starting a second OpenClaw process in the unit test.
const bridgeDir = testing.getNativeHookRelayBridgeDirForTests();
await fs.mkdir(bridgeDir, { recursive: true, mode: 0o700 });
const registryPath = testing.getNativeHookRelayBridgeRegistryPathForTests(relayId);
writeFileSync(
registryPath,
`${JSON.stringify({
version: 1,
writeNativeHookRelayBridgeRecord({
record: {
relayId,
pid: record.pid,
hostname: "127.0.0.1",
port: 9,
token: `token-${relayId}`,
token: "test-token-placeholder",
expiresAtMs: record.expiresAtMs,
})}\n`,
{ mode: 0o600 },
);
return registryPath;
},
});
return relayId;
}
function uniqueNativeHookRelayIdForTests(prefix: string): string {
return `${prefix}-${randomUUID()}`;
}
function nativeHookRelayStateDbArgForTests(): string {
return `--state-db ${resolveOpenClawStateSqlitePath()}`;
}
function openDeferredNativeHookRelayBridgeRequest(
record: Record<string, unknown>,
record: Pick<NativeHookRelayBridgeRecord, "hostname" | "port" | "token">,
payload: Record<string, unknown>,
): {
connected: Promise<void>;
@@ -137,12 +142,12 @@ function openDeferredNativeHookRelayBridgeRequest(
});
const req = httpRequest(
{
hostname: String(record.hostname),
hostname: record.hostname,
method: "POST",
path: "/invoke",
port: Number(record.port),
port: record.port,
headers: {
authorization: `Bearer ${String(record.token)}`,
authorization: `Bearer ${record.token}`,
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
},
@@ -248,15 +253,15 @@ describe("native hook relay registry", () => {
);
expect(relay.commandForEvent("pre_tool_use")).toBe(
"/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " +
`${relay.relayId} --generation ${relay.generation} --event pre_tool_use --timeout 1234`,
`${relay.relayId} ${nativeHookRelayStateDbArgForTests()} --generation ${relay.generation} --event pre_tool_use --timeout 1234`,
);
expect(relay.commandForEvent("pre_tool_use", { timeoutMs: 900 })).toBe(
"/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " +
`${relay.relayId} --generation ${relay.generation} --event pre_tool_use --timeout 900`,
`${relay.relayId} ${nativeHookRelayStateDbArgForTests()} --generation ${relay.generation} --event pre_tool_use --timeout 900`,
);
expect(relay.commandForEvent("pre_tool_use", { timeoutMs: 2_000 })).toBe(
"/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " +
`${relay.relayId} --generation ${relay.generation} --event pre_tool_use --timeout 1234`,
`${relay.relayId} ${nativeHookRelayStateDbArgForTests()} --generation ${relay.generation} --event pre_tool_use --timeout 1234`,
);
});
@@ -543,7 +548,7 @@ describe("native hook relay registry", () => {
expect(relay.shouldRelayEvent("permission_request")).toBe(true);
expect(relay.commandForEvent("pre_tool_use")).toBe(
"/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " +
`${relay.relayId} --generation ${relay.generation} --event pre_tool_use --pre-tool-use-unavailable noop --timeout 1234`,
`${relay.relayId} ${nativeHookRelayStateDbArgForTests()} --generation ${relay.generation} --event pre_tool_use --pre-tool-use-unavailable noop --timeout 1234`,
);
});
@@ -566,7 +571,7 @@ describe("native hook relay registry", () => {
expect(relay.shouldRelayEvent("pre_tool_use")).toBe(true);
expect(relay.commandForEvent("pre_tool_use")).toBe(
"/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " +
`${relay.relayId} --generation ${relay.generation} --event pre_tool_use --timeout 1234`,
`${relay.relayId} ${nativeHookRelayStateDbArgForTests()} --generation ${relay.generation} --event pre_tool_use --timeout 1234`,
);
});
@@ -586,7 +591,7 @@ describe("native hook relay registry", () => {
expect(relay.shouldRelayEvent("pre_tool_use")).toBe(true);
expect(relay.commandForEvent("pre_tool_use")).toBe(
"/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " +
`${relay.relayId} --generation ${relay.generation} --event pre_tool_use --timeout 1234`,
`${relay.relayId} ${nativeHookRelayStateDbArgForTests()} --generation ${relay.generation} --event pre_tool_use --timeout 1234`,
);
});
@@ -635,7 +640,7 @@ describe("native hook relay registry", () => {
expect(relay.shouldRelayEvent("before_agent_finalize")).toBe(false);
expect(relay.commandForEvent("post_tool_use")).toBe(
"/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " +
`${relay.relayId} --generation ${relay.generation} --event post_tool_use --timeout 1234`,
`${relay.relayId} ${nativeHookRelayStateDbArgForTests()} --generation ${relay.generation} --event post_tool_use --timeout 1234`,
);
});
@@ -657,7 +662,7 @@ describe("native hook relay registry", () => {
expect(relay.shouldRelayEvent("before_agent_finalize")).toBe(true);
expect(relay.commandForEvent("before_agent_finalize")).toBe(
"/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " +
`${relay.relayId} --generation ${relay.generation} --event before_agent_finalize --timeout 1234`,
`${relay.relayId} ${nativeHookRelayStateDbArgForTests()} --generation ${relay.generation} --event before_agent_finalize --timeout 1234`,
);
});
@@ -1005,8 +1010,34 @@ describe("native hook relay registry", () => {
expect(response).toEqual({ stdout: "", stderr: "", exitCode: 0 });
});
it("prunes dead foreign direct bridge registry files during registration", async () => {
const stalePath = await writeForeignNativeHookRelayBridgeRecordForTests(
it("restores a missing direct bridge record during renewal", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
relayId: "codex-restored-bridge-session",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["pre_tool_use"],
ttlMs: 10_000,
});
const before = await waitForNativeHookRelayBridgeRecord(relay.relayId);
expect(
deleteNativeHookRelayBridgeRecordIfOwned({
...before,
stateDbPath: resolveOpenClawStateSqlitePath(),
}),
).toBe(true);
expect(testing.getNativeHookRelayBridgeRecordForTests(relay.relayId)).toBeUndefined();
relay.renew(20_000);
const after = await waitForNativeHookRelayBridgeRecord(relay.relayId);
expect(after.port).toBe(before.port);
expect(after.token).toBe(before.token);
expect(after.expiresAtMs).toBeGreaterThan(before.expiresAtMs);
});
it("prunes dead foreign direct bridge records during registration", async () => {
const staleRelayId = await writeForeignNativeHookRelayBridgeRecordForTests(
uniqueNativeHookRelayIdForTests("codex-dead-foreign-bridge"),
{
pid: 9_999_991,
@@ -1029,18 +1060,18 @@ describe("native hook relay registry", () => {
});
expect(kill).toHaveBeenCalledWith(9_999_991, 0);
await expect(fs.stat(stalePath)).rejects.toMatchObject({ code: "ENOENT" });
expect(testing.getNativeHookRelayBridgeRecordForTests(staleRelayId)).toBeUndefined();
});
it("prunes expired foreign direct bridge registry files even when their pid is alive", async () => {
const unrelatedLivePath = await writeForeignNativeHookRelayBridgeRecordForTests(
it("prunes expired foreign direct bridge records even when their pid is alive", async () => {
const unrelatedLiveRelayId = await writeForeignNativeHookRelayBridgeRecordForTests(
uniqueNativeHookRelayIdForTests("codex-unrelated-live-foreign-bridge"),
{
pid: 9_999_994,
expiresAtMs: Date.now() + 60_000,
},
);
const stalePath = await writeForeignNativeHookRelayBridgeRecordForTests(
const staleRelayId = await writeForeignNativeHookRelayBridgeRecordForTests(
uniqueNativeHookRelayIdForTests("codex-expired-foreign-bridge"),
{
pid: 9_999_992,
@@ -1064,12 +1095,12 @@ describe("native hook relay registry", () => {
expect(kill).toHaveBeenCalledWith(9_999_994, 0);
expect(kill).not.toHaveBeenCalledWith(9_999_992, 0);
await expect(fs.stat(stalePath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.stat(unrelatedLivePath)).resolves.toBeDefined();
expect(testing.getNativeHookRelayBridgeRecordForTests(staleRelayId)).toBeUndefined();
expect(testing.getNativeHookRelayBridgeRecordForTests(unrelatedLiveRelayId)).toBeDefined();
});
it("preserves live unexpired foreign direct bridge registry files during registration", async () => {
const livePath = await writeForeignNativeHookRelayBridgeRecordForTests(
it("preserves live unexpired foreign direct bridge records during registration", async () => {
const liveRelayId = await writeForeignNativeHookRelayBridgeRecordForTests(
uniqueNativeHookRelayIdForTests("codex-live-foreign-bridge"),
{
pid: 9_999_993,
@@ -1083,24 +1114,20 @@ describe("native hook relay registry", () => {
return true;
});
try {
registerNativeHookRelay({
provider: "codex",
relayId: "codex-preserve-live-foreign-bridge-session",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["pre_tool_use"],
});
registerNativeHookRelay({
provider: "codex",
relayId: "codex-preserve-live-foreign-bridge-session",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["pre_tool_use"],
});
expect(kill).toHaveBeenCalledWith(9_999_993, 0);
await expect(fs.stat(livePath)).resolves.toBeDefined();
} finally {
rmSync(livePath, { force: true });
}
expect(kill).toHaveBeenCalledWith(9_999_993, 0);
expect(testing.getNativeHookRelayBridgeRecordForTests(liveRelayId)).toBeDefined();
});
it("preserves foreign direct bridge registry files when liveness is unknown", async () => {
const livePath = await writeForeignNativeHookRelayBridgeRecordForTests(
it("preserves foreign direct bridge records when liveness is unknown", async () => {
const liveRelayId = await writeForeignNativeHookRelayBridgeRecordForTests(
uniqueNativeHookRelayIdForTests("codex-unknown-liveness-foreign-bridge"),
{
pid: 9_999_994,
@@ -1114,23 +1141,19 @@ describe("native hook relay registry", () => {
return true;
});
try {
registerNativeHookRelay({
provider: "codex",
relayId: "codex-preserve-unknown-liveness-foreign-bridge-session",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["pre_tool_use"],
});
registerNativeHookRelay({
provider: "codex",
relayId: "codex-preserve-unknown-liveness-foreign-bridge-session",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["pre_tool_use"],
});
expect(kill).toHaveBeenCalledWith(9_999_994, 0);
await expect(fs.stat(livePath)).resolves.toBeDefined();
} finally {
rmSync(livePath, { force: true });
}
expect(kill).toHaveBeenCalledWith(9_999_994, 0);
expect(testing.getNativeHookRelayBridgeRecordForTests(liveRelayId)).toBeDefined();
});
it("keeps direct bridge registry files private and loopback-only", async () => {
it("accepts only loopback direct bridge records", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
relayId: "codex-private-bridge-session",
@@ -1140,20 +1163,14 @@ describe("native hook relay registry", () => {
});
const record = await waitForNativeHookRelayBridgeRecord(relay.relayId);
const bridgeDir = testing.getNativeHookRelayBridgeDirForTests();
const registryPath = testing.getNativeHookRelayBridgeRegistryPathForTests(relay.relayId);
expect(statSync(bridgeDir).mode & 0o077).toBe(0);
expect(statSync(registryPath).mode & 0o077).toBe(0);
writeFileSync(
registryPath,
`${JSON.stringify({
writeNativeHookRelayBridgeRecord({
// Simulate a hostile/corrupt database row outside the typed store contract.
record: {
...record,
hostname: "192.0.2.1",
expiresAtMs: Date.now() + 10_000,
})}\n`,
{ mode: 0o600 },
);
} as unknown as NativeHookRelayBridgeRecord,
});
await expect(
invokeNativeHookRelayBridge({
@@ -1190,15 +1207,13 @@ describe("native hook relay registry", () => {
const firstRecord = await waitForNativeHookRelayBridgeRecord(first.relayId);
await waitForNativeHookRelayBridgeRecord(second.relayId);
writeFileSync(
testing.getNativeHookRelayBridgeRegistryPathForTests(second.relayId),
`${JSON.stringify({
writeNativeHookRelayBridgeRecord({
record: {
...firstRecord,
relayId: second.relayId,
expiresAtMs: Date.now() + 10_000,
})}\n`,
{ mode: 0o600 },
);
},
});
await expect(
invokeNativeHookRelayBridge({
@@ -1238,16 +1253,14 @@ describe("native hook relay registry", () => {
if (!address || typeof address === "string") {
throw new Error("test bridge server address unavailable");
}
writeFileSync(
testing.getNativeHookRelayBridgeRegistryPathForTests(relay.relayId),
`${JSON.stringify({
writeNativeHookRelayBridgeRecord({
record: {
...record,
port: address.port,
token: "test-token",
expiresAtMs: Date.now() + 10_000,
})}\n`,
{ mode: 0o600 },
);
},
});
await expect(
invokeNativeHookRelayBridge({
+143 -194
View File
@@ -2,15 +2,7 @@
* Bridges native harness hook events through registered relay processes.
*/
import { createHash, randomUUID } from "node:crypto";
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
} from "node:fs";
import { existsSync } from "node:fs";
import {
createServer,
request as httpRequest,
@@ -18,7 +10,6 @@ import {
type Server,
type ServerResponse,
} from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import {
asDateTimestampMs,
@@ -29,11 +20,11 @@ import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { toErrorObject } from "../../infra/errors.js";
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
import { privateFileStoreSync } from "../../infra/private-file-store.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { listAgentToolResultMiddlewares } from "../../plugins/agent-tool-result-middleware.js";
import { hasGlobalHooks } from "../../plugins/hook-runner-global.js";
import { PluginApprovalResolutions } from "../../plugins/types.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import {
cancelDeferredPluginToolApproval,
hasBeforeToolCallPolicy,
@@ -49,6 +40,15 @@ import { payloadTextResult } from "../tools/common.js";
import { callGatewayTool } from "../tools/gateway.js";
import { runAgentHarnessAfterToolCallHook } from "./hook-helpers.js";
import { runAgentHarnessBeforeAgentFinalizeHook } from "./lifecycle-hook-helpers.js";
import {
clearNativeHookRelayBridgeRecordsForTests,
deleteNativeHookRelayBridgeRecordIfOwned,
pruneNativeHookRelayBridgeRecords,
readNativeHookRelayBridgeRecord as readNativeHookRelayBridgeRecordFromStore,
renewOrRestoreNativeHookRelayBridgeRecord,
writeNativeHookRelayBridgeRecord,
type NativeHookRelayBridgeRecord,
} from "./native-hook-relay-store.js";
import { createAgentToolResultMiddlewareRunner } from "./tool-result-middleware.js";
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
@@ -169,6 +169,7 @@ type InvokeNativeHookRelayParams = {
type InvokeNativeHookRelayBridgeParams = InvokeNativeHookRelayParams & {
registrationTimeoutMs?: number;
stateDbPath?: string;
timeoutMs?: number;
};
@@ -332,21 +333,11 @@ type NativeHookRelayDeferredApprovalOutcome =
type NativeHookRelayBridgeRegistration = {
relayId: string;
registryPath: string;
stateDbPath: string;
token: string;
server: Server;
};
type NativeHookRelayBridgeRecord = {
version: 1;
relayId: string;
pid: number;
hostname: string;
port: number;
token: string;
expiresAtMs: number;
};
type NativeHookRelayBridgeRequestAuth = {
provider: NativeHookRelayProvider;
relayId: string;
@@ -437,7 +428,10 @@ export function registerNativeHookRelay(
throw new Error("Native hook relay expiry is outside the supported Date range");
}
const allowedEvents = normalizeAllowedEvents(params.allowedEvents);
unregisterNativeHookRelay(relayId);
const stateDbPath = resolveOpenClawStateSqlitePath();
unregisterNativeHookRelay(relayId, undefined, {
deferBridgeRecordRemovalMs: NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS,
});
const registration: ActiveNativeHookRelayRegistration = {
relayId,
provider: params.provider,
@@ -459,14 +453,15 @@ export function registerNativeHookRelay(
...(params.onPreToolUseFailure ? { onPreToolUseFailure: params.onPreToolUseFailure } : {}),
};
relays.set(relayId, registration);
registerNativeHookRelayBridge(registration);
registerNativeHookRelayBridge(registration, stateDbPath);
const handle: ActiveNativeHookRelayRegistrationHandle = {
...registration,
shouldRelayEvent: (event) => nativeHookRelayEventHasLocalWork(registration, event),
commandForEvent: (event, options) =>
buildNativeHookRelayCommand({
buildNativeHookRelayCommandWithStateDatabase({
provider: params.provider,
relayId,
stateDbPath,
generation: registration.generation,
event,
preToolUseUnavailable:
@@ -490,12 +485,30 @@ export function registerNativeHookRelay(
if (renewedExpiresAtMs === undefined) {
return;
}
current.expiresAtMs = renewedExpiresAtMs;
handle.expiresAtMs = renewedExpiresAtMs;
const bridge = relayBridges.get(relayId);
if (bridge && bridge.server.listening) {
writeNativeHookRelayBridgeRecordForRegistration(current, bridge);
const record = resolveNativeHookRelayBridgeRecord(current, bridge, renewedExpiresAtMs);
if (!record) {
return;
}
try {
if (
!renewOrRestoreNativeHookRelayBridgeRecord({
record,
stateDbPath: bridge.stateDbPath,
})
) {
log.debug("native hook relay bridge record ownership changed", { relayId });
unregisterNativeHookRelay(relayId, current);
return;
}
} catch (error) {
log.debug("failed to renew native hook relay bridge record", { error, relayId });
return;
}
}
current.expiresAtMs = renewedExpiresAtMs;
handle.expiresAtMs = renewedExpiresAtMs;
},
unregister: () => unregisterNativeHookRelay(relayId, registration),
};
@@ -505,11 +518,12 @@ export function registerNativeHookRelay(
function unregisterNativeHookRelay(
relayId: string,
expectedRegistration?: ActiveNativeHookRelayRegistration,
options?: { deferBridgeRecordRemovalMs?: number },
): void {
if (expectedRegistration && relays.get(relayId) !== expectedRegistration) {
return;
}
unregisterNativeHookRelayBridge(relayId);
unregisterNativeHookRelayBridge(relayId, options);
relays.delete(relayId);
removeNativeHookRelayInvocations(relayId);
removeNativeHookRelayPreToolUseApprovals(relayId);
@@ -574,6 +588,21 @@ export function buildNativeHookRelayCommand(params: {
executable?: string;
nice?: number | false;
nodeExecutable?: string;
}): string {
return buildNativeHookRelayCommandWithStateDatabase(params);
}
function buildNativeHookRelayCommandWithStateDatabase(params: {
provider: NativeHookRelayProvider;
relayId: string;
stateDbPath?: string;
generation?: string;
event: NativeHookRelayEvent;
preToolUseUnavailable?: "noop";
timeoutMs?: number;
executable?: string;
nice?: number | false;
nodeExecutable?: string;
}): string {
const timeoutMs = normalizePositiveInteger(params.timeoutMs, DEFAULT_RELAY_TIMEOUT_MS);
const executable = params.executable ?? resolveOpenClawCliExecutable();
@@ -591,6 +620,7 @@ export function buildNativeHookRelayCommand(params: {
params.provider,
"--relay-id",
params.relayId,
...(params.stateDbPath ? ["--state-db", params.stateDbPath] : []),
...(params.generation ? ["--generation", params.generation] : []),
"--event",
params.event,
@@ -835,7 +865,7 @@ export async function invokeNativeHookRelayBridge(
let lastError: unknown = new Error("native hook relay bridge not found");
while (Date.now() - startedAt < timeoutMs) {
try {
const record = readNativeHookRelayBridgeRecord(relayId);
const record = readNativeHookRelayBridgeRecord(relayId, params.stateDbPath);
if (Date.now() > record.expiresAtMs) {
throw new Error("native hook relay bridge expired");
}
@@ -1005,60 +1035,35 @@ function isNativeHookRelayBridgePidDead(pid: number): boolean {
}
}
function registerNativeHookRelayBridge(registration: ActiveNativeHookRelayRegistration): void {
// Prune actually stale bridge files from prior gateway processes. The bridge
// directory is scoped by OS user (uid) and is shared across all OpenClaw
// gateways/profiles run by that user, so a record with a non-current PID is
// NOT automatically stale — it can legitimately belong to another live
// gateway under the same uid. Only prune records whose owning PID is dead
// or whose expiry has passed; leave live foreign records alone.
function registerNativeHookRelayBridge(
registration: ActiveNativeHookRelayRegistration,
stateDbPath: string,
): void {
// Liveness checks stay outside the write transaction. The store rereads each
// authoritative row before deletion so renewal or replacement wins the race.
try {
const staleDir = ensureNativeHookRelayBridgeDir();
const now = Date.now();
for (const name of readdirSync(staleDir)) {
if (!name.endsWith(".json")) {
continue;
}
const full = path.join(staleDir, name);
try {
const rec = JSON.parse(readFileSync(full, "utf8")) as {
pid?: number;
expiresAtMs?: number;
};
if (!rec || typeof rec.pid !== "number" || rec.pid === process.pid) {
continue;
}
const expired = typeof rec.expiresAtMs === "number" && now > rec.expiresAtMs;
const deadPid = !expired && isNativeHookRelayBridgePidDead(rec.pid);
if (!expired && !deadPid) {
// Live foreign record from another same-uid gateway/profile. Preserve it.
continue;
}
rmSync(full, { force: true });
log.debug("pruned stale native hook relay bridge file", {
file: name,
stalePid: rec.pid,
currentPid: process.pid,
reason: deadPid ? "dead-pid" : "expired",
});
} catch {
// ignore unparseable / racing files
}
const pruned = pruneNativeHookRelayBridgeRecords({
currentPid: process.pid,
isPidDead: isNativeHookRelayBridgePidDead,
stateDbPath,
});
for (const row of pruned) {
log.debug("pruned stale native hook relay bridge record", {
relayId: row.relayId,
stalePid: row.pid,
currentPid: process.pid,
reason: row.reason,
});
}
} catch (error) {
log.debug("native hook relay bridge dir prune skipped", { error });
log.debug("native hook relay bridge record prune skipped", { error });
}
unregisterNativeHookRelayBridge(registration.relayId, {
deferRegistryRemovalMs: NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS,
});
unregisterNativeHookRelayBridge(registration.relayId);
const token = randomUUID();
const bridgeDir = ensureNativeHookRelayBridgeDir();
const bridgeKey = nativeHookRelayBridgeKey(registration.relayId);
const registryPath = path.join(bridgeDir, `${bridgeKey}.json`);
const server = createServer();
const bridge: NativeHookRelayBridgeRegistration = {
relayId: registration.relayId,
registryPath,
stateDbPath,
token,
server,
};
@@ -1079,7 +1084,14 @@ function registerNativeHookRelayBridge(registration: ActiveNativeHookRelayRegist
if (relayBridges.get(registration.relayId) !== bridge) {
return;
}
writeNativeHookRelayBridgeRecordForRegistration(registration, bridge);
try {
writeNativeHookRelayBridgeRecordForRegistration(registration, bridge);
} catch (error) {
log.debug("failed to publish native hook relay bridge record", {
error,
relayId: registration.relayId,
});
}
});
server.unref();
}
@@ -1088,28 +1100,43 @@ function writeNativeHookRelayBridgeRecordForRegistration(
registration: ActiveNativeHookRelayRegistration,
bridge: NativeHookRelayBridgeRegistration,
): void {
const record = resolveNativeHookRelayBridgeRecord(registration, bridge);
if (!record) {
return;
}
writeNativeHookRelayBridgeRecord({
record,
stateDbPath: bridge.stateDbPath,
});
}
function resolveNativeHookRelayBridgeRecord(
registration: ActiveNativeHookRelayRegistration,
bridge: NativeHookRelayBridgeRegistration,
expiresAtMs = registration.expiresAtMs,
): NativeHookRelayBridgeRecord | undefined {
const address = bridge.server.address();
if (!address || typeof address === "string") {
log.debug("native hook relay bridge server address unavailable", {
relayId: registration.relayId,
});
return;
return undefined;
}
const { token } = bridge;
const record: NativeHookRelayBridgeRecord = {
version: 1,
relayId: registration.relayId,
pid: process.pid,
hostname: "127.0.0.1",
port: address.port,
token: bridge.token,
expiresAtMs: registration.expiresAtMs,
token,
expiresAtMs,
};
writeNativeHookRelayBridgeRecord(bridge.registryPath, record);
return record;
}
function unregisterNativeHookRelayBridge(
relayId: string,
options?: { deferRegistryRemovalMs?: number },
options?: { deferBridgeRecordRemovalMs?: number },
): void {
const bridge = relayBridges.get(relayId);
if (!bridge) {
@@ -1117,23 +1144,28 @@ function unregisterNativeHookRelayBridge(
}
relayBridges.delete(relayId);
bridge.server.close();
const record = readNativeHookRelayBridgeRecordIfExists(relayId);
if (record?.token === bridge.token) {
const deferRegistryRemovalMs = normalizePositiveInteger(options?.deferRegistryRemovalMs, 0);
if (deferRegistryRemovalMs > 0) {
// During stable-id replacement, leave the old record in place until the
// new bridge writes over it. Hook subprocesses can then retry
// ECONNREFUSED/stale-registration instead of observing a missing relay.
const timeout = setTimeout(() => {
if (readNativeHookRelayBridgeRecordIfExists(relayId)?.token === bridge.token) {
rmSync(bridge.registryPath, { force: true });
}
}, deferRegistryRemovalMs);
timeout.unref();
return;
const removeRecord = () => {
try {
deleteNativeHookRelayBridgeRecordIfOwned({
...bridge,
pid: process.pid,
});
} catch (error) {
log.debug("failed to remove native hook relay bridge record", { error, relayId });
}
rmSync(bridge.registryPath, { force: true });
};
const deferBridgeRecordRemovalMs = normalizePositiveInteger(
options?.deferBridgeRecordRemovalMs,
0,
);
if (deferBridgeRecordRemovalMs > 0) {
// During stable-id replacement, retain the old locator until the successor
// upserts. The token-scoped timer cannot delete that successor.
const timeout = setTimeout(removeRecord, deferBridgeRecordRemovalMs);
timeout.unref();
return;
}
removeRecord();
}
async function handleNativeHookRelayBridgeRequest(
@@ -1233,8 +1265,11 @@ function writeNativeHookRelayBridgeJson(
res.end(body);
}
function readNativeHookRelayBridgeRecord(relayId: string): NativeHookRelayBridgeRecord {
const record = readNativeHookRelayBridgeRecordIfExists(relayId);
function readNativeHookRelayBridgeRecord(
relayId: string,
stateDbPath?: string,
): NativeHookRelayBridgeRecord {
const record = readNativeHookRelayBridgeRecordIfExists(relayId, stateDbPath);
if (!record) {
throw new Error("native hook relay bridge not found");
}
@@ -1243,66 +1278,22 @@ function readNativeHookRelayBridgeRecord(relayId: string): NativeHookRelayBridge
function readNativeHookRelayBridgeRecordIfExists(
relayId: string,
stateDbPath?: string,
): NativeHookRelayBridgeRecord | undefined {
const registryPath = nativeHookRelayBridgeRegistryPath(relayId);
try {
const parsed: unknown = JSON.parse(readFileSync(registryPath, "utf8"));
if (isNativeHookRelayBridgeRecord(parsed, relayId)) {
return parsed;
}
return readNativeHookRelayBridgeRecordFromStore({ relayId, stateDbPath });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
log.debug("failed to read native hook relay bridge registry", { error, relayId });
}
log.debug("failed to read native hook relay bridge record", { error, relayId });
}
return undefined;
}
function isNativeHookRelayBridgeRecord(
value: unknown,
relayId: string,
): value is NativeHookRelayBridgeRecord {
return (
isJsonObject(value) &&
value.version === 1 &&
value.relayId === relayId &&
typeof value.pid === "number" &&
Number.isInteger(value.pid) &&
value.hostname === "127.0.0.1" &&
typeof value.port === "number" &&
Number.isInteger(value.port) &&
value.port > 0 &&
value.port <= 65_535 &&
typeof value.token === "string" &&
value.token.length > 0 &&
typeof value.expiresAtMs === "number"
);
}
async function invokeNativeHookRelayBridgeRecord(params: {
record: NativeHookRelayBridgeRecord;
timeoutMs: number;
payload: InvokeNativeHookRelayParams;
}): Promise<NativeHookRelayProcessResponse> {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < params.timeoutMs) {
try {
return await postNativeHookRelayBridgeRecord({
...params,
timeoutMs: Math.max(1, params.timeoutMs - (Date.now() - startedAt)),
});
} catch (error) {
lastError = error;
if (!isRetryableNativeHookRelayBridgeError(error)) {
break;
}
await delay(
Math.min(NATIVE_HOOK_BRIDGE_RETRY_INTERVAL_MS, params.timeoutMs - (Date.now() - startedAt)),
);
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
return postNativeHookRelayBridgeRecord(params);
}
function postNativeHookRelayBridgeRecord(params: {
@@ -1401,50 +1392,6 @@ function isRetryableNativeHookRelayBridgeLookupError(params: {
);
}
function nativeHookRelayBridgeDir(): string {
const uid = typeof process.getuid === "function" ? process.getuid() : "nouid";
return path.join(tmpdir(), `openclaw-native-hook-relays-${uid}`);
}
function ensureNativeHookRelayBridgeDir(): string {
const bridgeDir = nativeHookRelayBridgeDir();
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
const stats = lstatSync(bridgeDir);
const expectedUid = typeof process.getuid === "function" ? process.getuid() : undefined;
if (!stats.isDirectory() || stats.isSymbolicLink()) {
throw new Error("unsafe native hook relay bridge directory");
}
if (expectedUid !== undefined && stats.uid !== expectedUid) {
throw new Error("unsafe native hook relay bridge directory owner");
}
if (process.platform !== "win32" && (stats.mode & 0o077) !== 0) {
chmodSync(bridgeDir, 0o700);
const repaired = lstatSync(bridgeDir);
if ((repaired.mode & 0o077) !== 0) {
throw new Error("unsafe native hook relay bridge directory permissions");
}
}
return bridgeDir;
}
function writeNativeHookRelayBridgeRecord(
registryPath: string,
record: NativeHookRelayBridgeRecord,
): void {
privateFileStoreSync(path.dirname(registryPath)).writeText(
path.basename(registryPath),
`${JSON.stringify(record)}\n`,
);
}
function nativeHookRelayBridgeRegistryPath(relayId: string): string {
return path.join(nativeHookRelayBridgeDir(), `${nativeHookRelayBridgeKey(relayId)}.json`);
}
function nativeHookRelayBridgeKey(relayId: string): string {
return createHash("sha256").update(relayId).digest("hex").slice(0, 32);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, Math.max(0, ms));
@@ -2463,6 +2410,7 @@ export const testing = {
pendingPreToolUseApprovals.clear();
permissionApprovalWindows.clear();
permissionAllowAlwaysApprovals.clear();
clearNativeHookRelayBridgeRecordsForTests();
nativeHookRelayPermissionApprovalRequester = requestNativeHookRelayPermissionApproval;
nativeHookRelayDeferredToolApprovalRequester = requestDeferredPluginToolApproval;
},
@@ -2473,10 +2421,11 @@ export const testing = {
return relays.get(relayId);
},
getNativeHookRelayBridgeDirForTests(): string {
return nativeHookRelayBridgeDir();
throw new Error("native hook relay bridge files were retired");
},
getNativeHookRelayBridgeRegistryPathForTests(relayId: string): string {
return nativeHookRelayBridgeRegistryPath(relayId);
void relayId;
throw new Error("native hook relay bridge files were retired");
},
getNativeHookRelayBridgeRecordForTests(relayId: string): Record<string, unknown> | undefined {
const record = readNativeHookRelayBridgeRecordIfExists(relayId);
+51
View File
@@ -6,6 +6,11 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
registerNativeHookRelay,
testing as nativeHookRelayTesting,
} from "../agents/harness/native-hook-relay.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const activeChildren = new Set<ChildProcessWithoutNullStreams>();
@@ -13,6 +18,7 @@ const outputTimeoutMs = 20_000;
const exitAfterOutputTimeoutMs = 5_000;
afterEach(async () => {
nativeHookRelayTesting.clearNativeHookRelaysForTests();
await Promise.all(Array.from(activeChildren, terminateChild));
});
@@ -202,6 +208,51 @@ async function runHooksRelay(params: { event: "post_tool_use" | "pre_tool_use";
}
describe("hooks CLI process lifecycle", () => {
it("uses the explicit relay database when the child has a different state directory", async () => {
const relay = registerNativeHookRelay({
provider: "codex",
relayId: "process-explicit-state-db",
sessionId: "session-1",
runId: "run-1",
allowedEvents: ["post_tool_use"],
});
await expect
.poll(() => nativeHookRelayTesting.getNativeHookRelayBridgeRecordForTests(relay.relayId))
.toBeDefined();
const childStateDir = path.join(tempDirs.make("openclaw-hooks-relay-other-state-"), "state");
await fs.mkdir(childStateDir, { recursive: true });
const result = await runHooksCli({
args: [
"hooks",
"relay",
"--provider",
"codex",
"--relay-id",
relay.relayId,
"--state-db",
resolveOpenClawStateSqlitePath(),
"--generation",
relay.generation,
"--event",
"post_tool_use",
"--timeout",
"5000",
],
label: "hooks relay explicit state database",
env: {
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_NO_RESPAWN: "1",
OPENCLAW_STATE_DIR: childStateDir,
},
stdin: JSON.stringify({ hook_event_name: "PostToolUse" }),
});
expect(result, result.stderr).toMatchObject({ code: 0, signal: null });
expect(result.stderr).toBe("");
expect(result.stdout).toBe("");
}, 60_000);
it("exits after one-shot outputs when plugins leave ref'd handles", async () => {
const fixture = await createLingeringPluginFixture();
+1
View File
@@ -546,6 +546,7 @@ export function registerHooksCli(program: Command): void {
.description("Internal native harness hook relay")
.requiredOption("--provider <provider>", "Native harness provider")
.requiredOption("--relay-id <id>", "Native hook relay id")
.option("--state-db <path>", "Shared state database path")
.option("--generation <generation>", "Native hook relay registration generation")
.requiredOption("--event <event>", "Native hook event")
.option(
+27
View File
@@ -21,6 +21,33 @@ function createWritableTextBuffer(): NodeJS.WritableStream & { text: () => strin
}
describe("native hook relay CLI", () => {
it("passes the explicit state database path to direct bridge lookup", async () => {
const invokeBridge = vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 }));
await expect(
runNativeHookRelayCli(
{
provider: "codex",
relayId: "relay-1",
stateDb: "/tmp/profile/state/openclaw.sqlite",
generation: "generation-1",
event: "post_tool_use",
},
{
stdin: createReadableTextStream("{}"),
invokeBridge: invokeBridge as never,
},
),
).resolves.toBe(0);
expect(invokeBridge).toHaveBeenCalledWith(
expect.objectContaining({
relayId: "relay-1",
stateDbPath: "/tmp/profile/state/openclaw.sqlite",
}),
);
});
it("reads Codex hook JSON from stdin and forwards it to the gateway relay", async () => {
const callGateway = vi.fn(async (_opts: unknown) => ({ stdout: "", stderr: "", exitCode: 0 }));
const stdout = createWritableTextBuffer();
+2
View File
@@ -16,6 +16,7 @@ const MAX_NATIVE_HOOK_STDIN_BYTES = 1024 * 1024;
export type NativeHookRelayCliOptions = {
provider?: string;
relayId?: string;
stateDb?: string;
generation?: string;
event?: string;
preToolUseUnavailable?: string;
@@ -94,6 +95,7 @@ export async function runNativeHookRelayCli(
invokeBridge({
provider,
relayId,
stateDbPath: opts.stateDb?.trim() || undefined,
generation,
event,
rawPayload,
@@ -278,6 +278,19 @@ describe("check-database-first-legacy-stores", () => {
]);
});
it("flags runtime writes to the retired native hook relay JSON registry", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`
import { promises as fs } from "node:fs";
import path from "node:path";
await fs.writeFile(path.join("/tmp", "openclaw-native-hook-relays-501", "relay.json"), "{}\n");
`,
"src/agents/harness/native-hook-relay-file-store.ts",
);
expect(violations).toEqual([{ kind: "legacy store filesystem write", line: 4 }]);
});
it("flags runtime writes to the retired subagent JSON registry", () => {
const violations = collectDatabaseFirstLegacyStoreViolations(
`
@@ -33,6 +33,7 @@ describe("SQLite transaction boundary guard", () => {
findSqliteTransactionBoundaryViolations(`
runSqliteImmediateTransactionSync(db, async () => await prepare());
runOpenClawAgentWriteTransaction(async (database) => await write(database), options);
runOpenClawStateWriteTransaction(async (database) => await write(database));
`),
).toEqual([
{
@@ -45,6 +46,11 @@ describe("SQLite transaction boundary guard", () => {
reason:
'passes an async callback to synchronous SQLite transaction helper "runOpenClawAgentWriteTransaction"',
},
{
line: 4,
reason:
'passes an async callback to synchronous SQLite transaction helper "runOpenClawStateWriteTransaction"',
},
]);
});