fix(hooks): expose typed gateway startup context

This commit is contained in:
Vincent Koc
2026-04-22 11:21:27 -07:00
parent 3e24898690
commit 6d003cbcee
9 changed files with 262 additions and 216 deletions

View File

@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
- CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl.
- Hooks/Slack: standardize shared message hook routing fields (`threadId` / `replyToId`) and stop Slack outbound delivery from re-running `message_sending` inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc.
- Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local `MEDIA:` attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev.
- Plugins/gateway hooks: expose startup config, workspace dir, and a live cron getter on the typed `gateway_start` hook, and move memory-core managed dreaming off the internal `gateway:startup` bridge so cron reconciliation stays on the public plugin hook path. Thanks @vincentkoc.
- Gateway/restart: preserve group and channel chat context when resuming an agent turn after a Gateway restart, so continuation replies keep the same prompt, routing, and tool-status behavior as the original conversation.
- Gateway/pairing: shared-secret loopback CLI clients now silently auto-approve `metadata-upgrade` pairing (platform / device family refresh) instead of being disconnected with `1008 pairing required`. This matches the scope-upgrade and role-upgrade behavior added in #69431 and unblocks non-interactive CLI automation when a paired-device record has a stale platform string (e.g. device key replicated across hosts, install migrated between OSes, or platform-string format changed between OpenClaw versions). Browser / Control-UI clients keep the existing approval-required flow for metadata changes.
- Gateway/pairing: treat any forwarded-header evidence (`Forwarded`, `X-Forwarded-*`, or `X-Real-IP`) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path.

View File

@@ -1,2 +1,2 @@
ba9b9d9b321b405fef89d4e95c1a3d629d1b956398a5b2a7f25b2a7654879783 plugin-sdk-api-baseline.json
8bbbee0ea2326148d4fd49f61fe74f83c5bb24c0742cfbf3609f43939fcd4c34 plugin-sdk-api-baseline.jsonl
475eafc1885c69203ac690a0ef3c1d1ddf6879d904a6980cf5f172c29ed70868 plugin-sdk-api-baseline.json
906bb0b167b155d2f766bd13f720c8c6ed8f349769d39f57ff5070045b96ef4c plugin-sdk-api-baseline.jsonl

View File

@@ -462,6 +462,7 @@ AI CLI backend such as `codex-cli`.
- `message_sending`: returning `{ cancel: false }` is treated as no decision (same as omitting `cancel`), not as an override.
- `message_received`: use the typed `threadId` field when you need inbound thread/topic routing. Keep `metadata` for channel-specific extras.
- `message_sending`: use typed `replyToId` / `threadId` routing fields before falling back to channel-specific `metadata`.
- `gateway_start`: use `ctx.config`, `ctx.workspaceDir`, and `ctx.getCron?.()` for gateway-owned startup state instead of relying on internal `gateway:startup` hooks.
### API object fields

View File

@@ -3,12 +3,6 @@ import path from "node:path";
import { enqueueSystemEvent, resetSystemEventsForTest } from "openclaw/plugin-sdk/infra-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearInternalHooks,
createInternalHookEvent,
registerInternalHook,
triggerInternalHook,
} from "../../../src/hooks/internal-hooks.js";
import {
__testing,
reconcileShortTermDreamingCronJob,
@@ -26,6 +20,8 @@ afterEach(() => {
resetSystemEventsForTest();
});
function clearInternalHooks(): void {}
type CronParam = NonNullable<Parameters<typeof reconcileShortTermDreamingCronJob>[0]["cron"]>;
type CronJobLike = Awaited<ReturnType<CronParam["list"]>>[number];
type CronAddInput = Parameters<CronParam["add"]>[0];
@@ -36,7 +32,7 @@ type DreamingPluginApiTestDouble = {
pluginConfig: Record<string, unknown>;
logger: ReturnType<typeof createLogger>;
runtime: unknown;
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => void;
registerHook: (event: string, handler: (event: unknown) => unknown) => void;
on: ReturnType<typeof vi.fn>;
};
@@ -156,6 +152,29 @@ function getBeforeAgentReplyHandler(
) => Promise<unknown>;
}
function getGatewayStartHandler(
onMock: ReturnType<typeof vi.fn>,
): (
event: { port: number },
ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown },
) => Promise<unknown> {
const call = onMock.mock.calls.find(([eventName]) => eventName === "gateway_start");
if (!call) {
throw new Error("gateway_start hook was not registered");
}
return call[1] as (
event: { port: number },
ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown },
) => Promise<unknown>;
}
async function triggerGatewayStart(
onMock: ReturnType<typeof vi.fn>,
ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown },
): Promise<void> {
await getGatewayStartHandler(onMock)({ port: 18789 }, ctx);
}
function registerShortTermPromotionDreamingForTest(api: DreamingPluginApiTestDouble): void {
registerShortTermPromotionDreaming(api as unknown as DreamingPluginApi);
}
@@ -380,17 +399,11 @@ describe("short-term dreaming config", () => {
});
});
describe("short-term dreaming startup event parsing", () => {
it("resolves cron service from gateway startup event deps", () => {
describe("short-term dreaming gateway_start context parsing", () => {
it("resolves cron service from the typed gateway_start cron getter", () => {
const harness = createCronHarness();
const resolved = __testing.resolveCronServiceFromStartupEvent({
type: "gateway",
action: "startup",
context: {
deps: {
cron: harness.cron,
},
},
const resolved = __testing.resolveCronServiceFromGatewayContext({
getCron: () => harness.cron,
});
expect(resolved).toBe(harness.cron);
});
@@ -720,40 +733,37 @@ describe("gateway startup reconciliation", () => {
clearInternalHooks();
const logger = createLogger();
const harness = createCronHarness();
const onMock = vi.fn();
const api: DreamingPluginApiTestDouble = {
config: { plugins: { entries: {} } },
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
on: vi.fn(),
registerHook: () => {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: {
hooks: { internal: { enabled: true } },
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "15 4 * * *",
timezone: "UTC",
},
await triggerGatewayStart(onMock, {
config: {
hooks: { internal: { enabled: true } },
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "15 4 * * *",
timezone: "UTC",
},
},
},
},
} as OpenClawConfig,
deps: { cron: harness.cron },
}),
);
},
} as OpenClawConfig,
getCron: () => harness.cron,
});
expect(harness.addCalls).toHaveLength(1);
expect(harness.addCalls[0]).toMatchObject({
@@ -795,21 +805,16 @@ describe("gateway startup reconciliation", () => {
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
registerHook: () => {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
const deps = { cron: harness.cron };
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps,
}),
);
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => harness.cron,
});
expect(harness.addCalls).toHaveLength(0);
@@ -870,21 +875,17 @@ describe("gateway startup reconciliation", () => {
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
registerHook: () => {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
const deps = { cron: startupHarness.cron };
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps,
}),
);
const cronRef = { current: startupHarness.cron };
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => cronRef.current,
});
expect(startupHarness.addCalls).toHaveLength(1);
const managed = startupHarness.jobs.find((job) =>
@@ -903,7 +904,7 @@ describe("gateway startup reconciliation", () => {
]
: [],
);
deps.cron = reloadedHarness.cron;
cronRef.current = reloadedHarness.cron;
api.config = {
plugins: {
entries: {
@@ -962,20 +963,16 @@ describe("gateway startup reconciliation", () => {
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
registerHook: () => {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps: { cron: harness.cron },
}),
);
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => harness.cron,
});
expect(harness.addCalls).toHaveLength(1);
harness.jobs.splice(
@@ -1028,20 +1025,16 @@ describe("gateway startup reconciliation", () => {
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
registerHook: () => {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps: { cron: harness.cron },
}),
);
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => harness.cron,
});
expect(harness.listCalls).toBe(1);
@@ -1084,20 +1077,16 @@ describe("gateway startup reconciliation", () => {
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
registerHook: () => {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps: { cron: harness.cron },
}),
);
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => harness.cron,
});
expect(harness.listCalls).toBe(1);
@@ -1140,20 +1129,16 @@ describe("gateway startup reconciliation", () => {
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
registerHook: () => {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps: { cron: harness.cron },
}),
);
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => harness.cron,
});
const sessionKey = "agent:main:main";
enqueueSystemEvent(constants.DREAMING_SYSTEM_EVENT_TEXT, {
@@ -1207,20 +1192,16 @@ describe("gateway startup reconciliation", () => {
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
registerHook: () => {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps: { cron: harness.cron },
}),
);
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => harness.cron,
});
enqueueSystemEvent(constants.DREAMING_SYSTEM_EVENT_TEXT, {
sessionKey: "agent:main:main",
@@ -1242,7 +1223,7 @@ describe("gateway startup reconciliation", () => {
}
});
it("does not emit the cron-unavailable warning on gateway:startup when deps.cron is missing (regression #69939)", async () => {
it("does not emit the cron-unavailable warning on gateway_start when cron is missing (regression #69939)", async () => {
clearInternalHooks();
const logger = createLogger();
const api: DreamingPluginApiTestDouble = {
@@ -1250,43 +1231,38 @@ describe("gateway startup reconciliation", () => {
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
registerHook: () => {},
on: vi.fn(),
};
try {
registerShortTermPromotionDreamingForTest(api);
// Simulate the startup race: gateway:startup fires before deps.cron is attached.
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: {
hooks: { internal: { enabled: true } },
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "15 4 * * *",
timezone: "UTC",
},
await triggerGatewayStart(api.on, {
config: {
hooks: { internal: { enabled: true } },
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "15 4 * * *",
timezone: "UTC",
},
},
},
},
} as OpenClawConfig,
deps: {},
}),
);
},
} as OpenClawConfig,
getCron: () => undefined,
});
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining("cron service unavailable"),
);
// The startup-path log should be demoted to debug instead.
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining("cron service not yet available at gateway:startup"),
expect.stringContaining("cron service not yet available at gateway_start"),
);
} finally {
clearInternalHooks();
@@ -1316,21 +1292,17 @@ describe("gateway startup reconciliation", () => {
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
registerHook: () => {},
on: onMock,
};
try {
registerShortTermPromotionDreamingForTest(api);
// Startup without cron — must stay silent on warn.
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps: {},
}),
);
await triggerGatewayStart(onMock, {
config: api.config,
getCron: () => undefined,
});
expect(logger.warn).not.toHaveBeenCalled();
// Now a runtime heartbeat reconciliation happens and cron is still missing

View File

@@ -21,7 +21,6 @@ import { writeDeepDreamingReport } from "./dreaming-markdown.js";
import { generateAndAppendDreamNarrative, type NarrativePhaseData } from "./dreaming-narrative.js";
import { runDreamingSweepPhases } from "./dreaming-phases.js";
import {
asRecord,
formatErrorMessage,
includesSystemEventToken,
normalizeTrimmedString,
@@ -95,11 +94,6 @@ type CronServiceLike = {
remove: (id: string) => Promise<{ removed?: boolean }>;
};
type StartupCronSourceRefs = {
context: Record<string, unknown>;
deps: Record<string, unknown> | null;
};
export type ShortTermPromotionDreamingConfig = {
enabled: boolean;
cron: string;
@@ -311,45 +305,8 @@ function resolveCronServiceFromCandidate(candidate: unknown): CronServiceLike |
return cron as CronServiceLike;
}
function resolveStartupCronSourceFromEvent(event: unknown): StartupCronSourceRefs | null {
const payload = asRecord(event);
if (!payload) {
return null;
}
if (payload.type !== "gateway" || payload.action !== "startup") {
return null;
}
const context = asRecord(payload.context);
if (!context) {
return null;
}
return { context, deps: asRecord(context.deps) };
}
function resolveCronServiceFromStartupSource(
source: StartupCronSourceRefs | null,
): CronServiceLike | null {
if (!source) {
return null;
}
return (
resolveCronServiceFromCandidate(source.context.cron) ??
resolveCronServiceFromCandidate(source.deps?.cron)
);
}
function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | null {
return resolveCronServiceFromStartupSource(resolveStartupCronSourceFromEvent(event));
}
function resolveStartupConfigFromEvent(event: unknown, fallback: OpenClawConfig): OpenClawConfig {
const startupEvent = asRecord(event);
const startupContext = asRecord(startupEvent?.context);
const startupCfg = asRecord(startupContext?.cfg);
if (!startupCfg) {
return fallback;
}
return startupCfg as OpenClawConfig;
function resolveCronServiceFromGatewayContext(context: { getCron?: () => unknown } | undefined) {
return resolveCronServiceFromCandidate(context?.getCron?.());
}
function resolveDreamingTriggerSessionKeys(sessionKey?: string): string[] {
@@ -675,7 +632,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
}
export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void {
let startupCronSource: StartupCronSourceRefs | null = null;
let resolveStartupCron: (() => CronServiceLike | null) | null = null;
let unavailableCronWarningEmitted = false;
let lastRuntimeReconcileAtMs = 0;
let lastRuntimeConfigKey: string | null = null;
@@ -699,12 +656,11 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
const reconcileManagedDreamingCron = async (params: {
reason: "startup" | "runtime";
startupEvent?: unknown;
startupConfig?: OpenClawConfig;
startupCron?: (() => CronServiceLike | null) | null;
}): Promise<ShortTermPromotionDreamingConfig> => {
const startupCfg =
params.reason === "startup" && params.startupEvent !== undefined
? resolveStartupConfigFromEvent(params.startupEvent, api.config)
: api.config;
params.reason === "startup" ? (params.startupConfig ?? api.config) : api.config;
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig:
resolveMemoryCorePluginConfig(startupCfg) ??
@@ -712,20 +668,18 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
api.pluginConfig,
cfg: startupCfg,
});
if (params.reason === "startup" && params.startupEvent !== undefined) {
startupCronSource = resolveStartupCronSourceFromEvent(params.startupEvent);
if (params.reason === "startup") {
resolveStartupCron = params.startupCron ?? null;
}
const cron = resolveCronServiceFromStartupSource(startupCronSource);
const cron = resolveStartupCron?.() ?? null;
const configKey = runtimeConfigKey(config);
if (!cron && config.enabled && !unavailableCronWarningEmitted) {
// The gateway emits `gateway:startup` via a deferred setTimeout, and
// `deps.cron` may not be attached to the event context yet when memory-core's
// startup hook fires (see issue #69939). Avoid logging a confusing warning on
// the startup path — the runtime reconciliation path (heartbeat-driven) will
// still warn if the cron service remains unavailable after boot.
// Avoid a noisy startup-path warning when the gateway has not exposed cron yet.
// The runtime reconciliation path (heartbeat-driven) will still warn if the
// cron service remains unavailable after boot.
if (params.reason === "startup") {
api.logger.debug?.(
"memory-core: cron service not yet available at gateway:startup; deferring to runtime reconciliation.",
"memory-core: cron service not yet available at gateway_start; deferring to runtime reconciliation.",
);
} else {
api.logger.warn(
@@ -760,22 +714,19 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
return config;
};
api.registerHook(
"gateway:startup",
async (event: unknown) => {
try {
await reconcileManagedDreamingCron({
reason: "startup",
startupEvent: event,
});
} catch (err) {
api.logger.error(
`memory-core: dreaming startup reconciliation failed: ${formatErrorMessage(err)}`,
);
}
},
{ name: "memory-core-short-term-dreaming-cron" },
);
api.on("gateway_start", async (_event, ctx) => {
try {
await reconcileManagedDreamingCron({
reason: "startup",
startupConfig: ctx.config,
startupCron: () => resolveCronServiceFromGatewayContext(ctx),
});
} catch (err) {
api.logger.error(
`memory-core: dreaming startup reconciliation failed: ${formatErrorMessage(err)}`,
);
}
});
api.on("before_agent_reply", async (event, ctx) => {
try {
@@ -811,7 +762,7 @@ export const __testing = {
buildManagedDreamingCronJob,
buildManagedDreamingPatch,
isManagedDreamingJob,
resolveCronServiceFromStartupEvent,
resolveCronServiceFromGatewayContext,
constants: {
MANAGED_DREAMING_CRON_NAME,
MANAGED_DREAMING_CRON_TAG,

View File

@@ -1,4 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
} from "../plugins/hook-types.js";
const hoisted = vi.hoisted(() => {
const startPluginServices = vi.fn(async () => null);
@@ -260,6 +264,57 @@ describe("startGatewayPostAttachRuntime", () => {
vi.useRealTimers();
}
});
it("passes typed gateway_start context with config, workspace dir, and a live cron getter", async () => {
const runGatewayStart = vi.fn<
(event: PluginHookGatewayStartEvent, ctx: PluginHookGatewayContext) => Promise<void>
>(async () => undefined);
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "gateway_start"),
runGatewayStart,
};
const initialCron = { list: vi.fn(), add: vi.fn(), update: vi.fn(), remove: vi.fn() };
const params = createPostAttachParams({
gatewayPluginConfigAtStart: {
hooks: { internal: { enabled: false } },
plugins: { entries: { demo: { enabled: true } } },
} as never,
deps: { cron: initialCron } as never,
});
await startGatewayPostAttachRuntime(
params,
createPostAttachRuntimeDeps({
getGlobalHookRunner: vi.fn(async () => hookRunner as never),
}),
);
await vi.waitFor(() => {
expect(runGatewayStart).toHaveBeenCalledTimes(1);
});
const firstCall = runGatewayStart.mock.calls[0];
if (!firstCall) {
throw new Error("gateway_start was not invoked");
}
const [event, ctx] = firstCall;
expect(event).toEqual({ port: 18789 });
expect(ctx).toMatchObject({
port: 18789,
config: params.gatewayPluginConfigAtStart,
workspaceDir: "/tmp/openclaw-workspace",
});
expect(typeof ctx.getCron).toBe("function");
const getCron = ctx.getCron;
if (!getCron) {
throw new Error("gateway_start context did not expose getCron");
}
expect(getCron()).toBe(initialCron);
const reloadedCron = { list: vi.fn(), add: vi.fn(), update: vi.fn(), remove: vi.fn() };
params.deps.cron = reloadedCron as never;
expect(getCron()).toBe(reloadedCron);
});
});
function createPostAttachRuntimeDeps(

View File

@@ -5,6 +5,7 @@ import { hasConfiguredInternalHooks } from "../hooks/configured.js";
import { isTruthyEnvValue } from "../infra/env.js";
import type { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
import type { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { PluginHookGatewayCronService } from "../plugins/hook-types.js";
import type { loadOpenClawPlugins } from "../plugins/loader.js";
import type { PluginServicesHandle } from "../plugins/services.js";
import {
@@ -474,7 +475,15 @@ export async function startGatewayPostAttachRuntime(
const hookRunner = await runtimeDeps.getGlobalHookRunner();
if (hookRunner?.hasHooks("gateway_start")) {
void hookRunner
.runGatewayStart({ port: params.port }, { port: params.port })
.runGatewayStart(
{ port: params.port },
{
port: params.port,
config: params.gatewayPluginConfigAtStart,
workspaceDir: params.defaultWorkspaceDir,
getCron: () => params.deps.cron as PluginHookGatewayCronService | undefined,
},
)
.catch((err) => {
params.log.warn(`gateway_start hook failed: ${String(err)}`);
});

View File

@@ -475,6 +475,9 @@ export type PluginHookSubagentEndedEvent = {
export type PluginHookGatewayContext = {
port?: number;
config?: OpenClawConfig;
workspaceDir?: string;
getCron?: () => PluginHookGatewayCronService | undefined;
};
export type PluginHookGatewayStartEvent = {
@@ -485,6 +488,55 @@ export type PluginHookGatewayStopEvent = {
reason?: string;
};
export type PluginHookGatewayCronJob = {
id: string;
name?: string;
description?: string;
enabled?: boolean;
schedule?: {
kind?: string;
expr?: string;
tz?: string;
};
sessionTarget?: string;
wakeMode?: string;
payload?: {
kind?: string;
text?: string;
};
createdAtMs?: number;
};
export type PluginHookGatewayCronCreateInput = {
name: string;
description: string;
enabled: boolean;
schedule: {
kind: string;
expr: string;
tz?: string;
};
sessionTarget: string;
wakeMode: string;
payload: {
kind: string;
text?: string;
};
};
export type PluginHookGatewayCronUpdateInput = Partial<PluginHookGatewayCronCreateInput>;
export type PluginHookGatewayCronRemoveResult = {
removed?: boolean;
};
export type PluginHookGatewayCronService = {
list: (opts?: { includeDisabled?: boolean }) => Promise<PluginHookGatewayCronJob[]>;
add: (input: PluginHookGatewayCronCreateInput) => Promise<unknown>;
update: (id: string, patch: PluginHookGatewayCronUpdateInput) => Promise<unknown>;
remove: (id: string) => Promise<PluginHookGatewayCronRemoveResult>;
};
export type PluginInstallTargetType = "skill" | "plugin";
export type PluginInstallRequestKind =
| "skill-install"

View File

@@ -31,7 +31,12 @@ async function expectGatewayHookCall(params: {
}
describe("gateway hook runner methods", () => {
const gatewayCtx = { port: 18789 };
const gatewayCtx = {
port: 18789,
config: {} as never,
workspaceDir: "/tmp/openclaw-workspace",
getCron: () => undefined,
};
it.each([
{