Merge 8b91aded4820c035e3008de0db58a920d1c3547f into 598f1826d8b2bc969aace2c6459824737667218c
This commit is contained in:
commit
d358d7d3ec
@ -1656,19 +1656,22 @@ public struct ConfigApplyParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
public let deliverycontext: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?)
|
||||
restartdelayms: Int?,
|
||||
deliverycontext: [String: AnyCodable]?)
|
||||
{
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
self.deliverycontext = deliverycontext
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@ -1677,6 +1680,7 @@ public struct ConfigApplyParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
case deliverycontext = "deliveryContext"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1686,19 +1690,22 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
public let deliverycontext: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?)
|
||||
restartdelayms: Int?,
|
||||
deliverycontext: [String: AnyCodable]?)
|
||||
{
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
self.deliverycontext = deliverycontext
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@ -1707,6 +1714,7 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
case deliverycontext = "deliveryContext"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3709,17 +3717,20 @@ public struct UpdateRunParams: Codable, Sendable {
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
public let timeoutms: Int?
|
||||
public let deliverycontext: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?,
|
||||
timeoutms: Int?)
|
||||
timeoutms: Int?,
|
||||
deliverycontext: [String: AnyCodable]?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
self.timeoutms = timeoutms
|
||||
self.deliverycontext = deliverycontext
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@ -3727,6 +3738,7 @@ public struct UpdateRunParams: Codable, Sendable {
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
case timeoutms = "timeoutMs"
|
||||
case deliverycontext = "deliveryContext"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1656,19 +1656,22 @@ public struct ConfigApplyParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
public let deliverycontext: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?)
|
||||
restartdelayms: Int?,
|
||||
deliverycontext: [String: AnyCodable]?)
|
||||
{
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
self.deliverycontext = deliverycontext
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@ -1677,6 +1680,7 @@ public struct ConfigApplyParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
case deliverycontext = "deliveryContext"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1686,19 +1690,22 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
public let deliverycontext: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?)
|
||||
restartdelayms: Int?,
|
||||
deliverycontext: [String: AnyCodable]?)
|
||||
{
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
self.deliverycontext = deliverycontext
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@ -1707,6 +1714,7 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
case deliverycontext = "deliveryContext"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3709,17 +3717,20 @@ public struct UpdateRunParams: Codable, Sendable {
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
public let timeoutms: Int?
|
||||
public let deliverycontext: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?,
|
||||
timeoutms: Int?)
|
||||
timeoutms: Int?,
|
||||
deliverycontext: [String: AnyCodable]?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
self.timeoutms = timeoutms
|
||||
self.deliverycontext = deliverycontext
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@ -3727,6 +3738,7 @@ public struct UpdateRunParams: Codable, Sendable {
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
case timeoutms = "timeoutMs"
|
||||
case deliverycontext = "deliveryContext"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -214,6 +214,37 @@ Think of it like a human reviewing their journal and updating their mental model
|
||||
|
||||
The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time.
|
||||
|
||||
## 🔄 Gateway Restarts — Do It Right!
|
||||
|
||||
**Never use `openclaw gateway restart` (CLI/shell).** It bypasses the restart sentinel, so you won't auto-resume or notify the user after restart. You'll just sit there silently until someone pings you.
|
||||
|
||||
**Always restart via the gateway tool** (action=restart) or via `config.patch`/`config.apply` — these write a sentinel file before restarting, which the new process consumes to wake you up and message the user automatically.
|
||||
|
||||
```bash
|
||||
# ✅ Correct: restart via gateway tool (action=restart, sessionKey, note)
|
||||
# ✅ Correct: config.patch with a key that requires restart (writes sentinel automatically)
|
||||
|
||||
# ❌ Wrong: openclaw gateway restart — no sentinel, silent after restart
|
||||
# ❌ Wrong: systemctl --user restart openclaw-gateway.service — same problem
|
||||
```
|
||||
|
||||
### Which config keys trigger a real restart vs dynamic reload?
|
||||
|
||||
**Full process restart** (sentinel written, agent wakes up):
|
||||
|
||||
- `gateway.*`, `discovery.*`, `plugins.*`, `canvasHost.*`
|
||||
- Any unrecognized/new config key
|
||||
|
||||
**Hot reload** (no restart, no sentinel needed):
|
||||
|
||||
- `hooks.*`, `cron.*`, `browser.*`, `models.*`, `agents.defaults.heartbeat`
|
||||
|
||||
**Dynamic no-op** (read on next access, no process action):
|
||||
|
||||
- `messages.*`, `agents.*`, `tools.*`, `routing.*`, `session.*`, `skills.*`, `secrets.*`, `meta.*`, `identity.*`, `logging.*`, `ui.*`
|
||||
|
||||
**Rule of thumb:** If you want a test restart, patch `discovery.mdns.mode` to its current value — it's recognized as a restart-triggering key even if the value is unchanged.
|
||||
|
||||
## Make It Yours
|
||||
|
||||
This is a starting point. Add your own conventions, style, and rules as you figure out what works.
|
||||
|
||||
@ -120,6 +120,29 @@ git commit -m "Add Clawd workspace"
|
||||
- **bird** — X/Twitter CLI,无需浏览器即可发推、回复、阅读话题和搜索。
|
||||
- **agent-tools** — 用于自动化和辅助脚本的实用工具包。
|
||||
|
||||
## 🔄 网关重启 — 正确做法!
|
||||
|
||||
**永远不要使用 `openclaw gateway restart`(CLI/shell)。** 这会绕过重启哨兵机制,导致重启后你无法自动恢复,也无法通知用户。你会静静地等待,直到有人 ping 你。
|
||||
|
||||
**始终通过 gateway 工具**(action=restart)或 `config.patch`/`config.apply` 触发重启——这些方式会在重启前写入哨兵文件,新进程启动后会消费该文件以唤醒你并自动通知用户。
|
||||
|
||||
### 哪些配置键会触发真正的重启?
|
||||
|
||||
**完整进程重启**(写入哨兵,代理唤醒):
|
||||
|
||||
- `gateway.*`、`discovery.*`、`plugins.*`、`canvasHost.*`
|
||||
- 任何无法识别的新配置键
|
||||
|
||||
**热重载**(无需重启,无需哨兵):
|
||||
|
||||
- `hooks.*`、`cron.*`、`browser.*`、`models.*`、`agents.defaults.heartbeat`
|
||||
|
||||
**动态无操作**(下次访问时读取,不触发任何进程操作):
|
||||
|
||||
- `messages.*`、`agents.*`、`tools.*`、`routing.*`、`session.*`、`skills.*`、`secrets.*`、`meta.*`
|
||||
|
||||
**经验法则:** 如果需要测试重启,将 `discovery.mdns.mode` 修改为当前值——即使值未改变,它也会触发重启流程。
|
||||
|
||||
## 使用说明
|
||||
|
||||
- 脚本编写优先使用 `openclaw` CLI;mac 应用处理权限。
|
||||
|
||||
@ -175,6 +175,10 @@ export function createOpenClawTools(
|
||||
...(imageGenerateTool ? [imageGenerateTool] : []),
|
||||
createGatewayTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
agentChannel: options?.agentChannel != null ? String(options.agentChannel) : undefined,
|
||||
agentTo: options?.agentTo,
|
||||
agentThreadId: options?.agentThreadId,
|
||||
agentAccountId: options?.agentAccountId,
|
||||
config: options?.config,
|
||||
}),
|
||||
createAgentsListTool({
|
||||
|
||||
453
src/agents/tools/gateway-tool.test.ts
Normal file
453
src/agents/tools/gateway-tool.test.ts
Normal file
@ -0,0 +1,453 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isRestartEnabled: vi.fn(() => true),
|
||||
resolveConfigSnapshotHash: vi.fn(() => undefined),
|
||||
extractDeliveryInfo: vi.fn(() => ({
|
||||
deliveryContext: {
|
||||
channel: "telegram",
|
||||
to: "+19995550001",
|
||||
accountId: undefined as string | undefined,
|
||||
},
|
||||
threadId: undefined as string | undefined,
|
||||
})),
|
||||
writeRestartSentinel: vi.fn(async () => undefined),
|
||||
scheduleGatewaySigusr1Restart: vi.fn(() => ({ ok: true })),
|
||||
formatDoctorNonInteractiveHint: vi.fn(() => ""),
|
||||
callGatewayTool: vi.fn(async () => ({})),
|
||||
readGatewayCallOptions: vi.fn(() => ({})),
|
||||
resolveGatewayTarget: vi.fn((): "local" | "remote" | undefined => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/commands.js", () => ({ isRestartEnabled: mocks.isRestartEnabled }));
|
||||
vi.mock("../../config/io.js", () => ({
|
||||
resolveConfigSnapshotHash: mocks.resolveConfigSnapshotHash,
|
||||
}));
|
||||
vi.mock("../../config/sessions.js", () => ({
|
||||
extractDeliveryInfo: mocks.extractDeliveryInfo,
|
||||
}));
|
||||
vi.mock("../../infra/restart-sentinel.js", () => ({
|
||||
writeRestartSentinel: mocks.writeRestartSentinel,
|
||||
formatDoctorNonInteractiveHint: mocks.formatDoctorNonInteractiveHint,
|
||||
}));
|
||||
vi.mock("../../infra/restart.js", () => ({
|
||||
scheduleGatewaySigusr1Restart: mocks.scheduleGatewaySigusr1Restart,
|
||||
}));
|
||||
vi.mock("./gateway.js", () => ({
|
||||
callGatewayTool: mocks.callGatewayTool,
|
||||
readGatewayCallOptions: mocks.readGatewayCallOptions,
|
||||
resolveGatewayTarget: mocks.resolveGatewayTarget,
|
||||
}));
|
||||
|
||||
import { createGatewayTool } from "./gateway-tool.js";
|
||||
|
||||
async function execTool(
|
||||
tool: ReturnType<typeof createGatewayTool>,
|
||||
params: Record<string, unknown>,
|
||||
) {
|
||||
return (tool as unknown as { execute: (id: string, args: unknown) => Promise<unknown> }).execute(
|
||||
"test-id",
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
function getCallArg<T>(mockFn: { mock: { calls: unknown[] } }, callIdx: number, argIdx: number): T {
|
||||
const calls = mockFn.mock.calls as unknown[][];
|
||||
return calls[callIdx]?.[argIdx] as T;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers to build common test fixtures
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makePatchParams(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
action: "config.patch",
|
||||
raw: '{"key":"value"}',
|
||||
baseHash: "abc123",
|
||||
sessionKey: "agent:main:main",
|
||||
note: "test patch",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTool(
|
||||
opts: {
|
||||
agentSessionKey?: string;
|
||||
agentChannel?: string;
|
||||
agentTo?: string;
|
||||
agentThreadId?: string;
|
||||
agentAccountId?: string;
|
||||
} = {},
|
||||
) {
|
||||
return createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "discord",
|
||||
agentTo: "123456789",
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 1 — Live delivery context for RPC actions (config.apply / config.patch / update.run)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createGatewayTool – RPC delivery context forwarding", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Happy path: full live context forwarded ──────────────────────────────
|
||||
|
||||
it("forwards liveDeliveryContext when agentChannel and agentTo are both present", async () => {
|
||||
await execTool(makeTool(), makePatchParams());
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toEqual({
|
||||
channel: "discord",
|
||||
to: "123456789",
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes agentAccountId in forwarded context when provided", async () => {
|
||||
await execTool(makeTool({ agentAccountId: "acct-99" }), makePatchParams());
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.accountId).toBe("acct-99");
|
||||
});
|
||||
|
||||
it("includes agentThreadId in forwarded context when provided", async () => {
|
||||
await execTool(
|
||||
makeTool({ agentChannel: "slack", agentTo: "C012AB3CD", agentThreadId: "1234567890.123" }),
|
||||
makePatchParams({ sessionKey: "agent:main:main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.threadId).toBe("1234567890.123");
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("slack");
|
||||
});
|
||||
|
||||
it("forwards live context for config.apply as well as config.patch", async () => {
|
||||
await execTool(makeTool(), {
|
||||
action: "config.apply",
|
||||
raw: '{"key":"value"}',
|
||||
baseHash: "abc123",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("discord");
|
||||
});
|
||||
|
||||
it("forwards live context for update.run", async () => {
|
||||
await execTool(makeTool(), {
|
||||
action: "update.run",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("discord");
|
||||
});
|
||||
|
||||
// ── Partial live context — must be suppressed ────────────────────────────
|
||||
|
||||
it("suppresses deliveryContext when agentTo is missing", async () => {
|
||||
await execTool(makeTool({ agentTo: undefined }), makePatchParams());
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses deliveryContext when agentChannel is missing", async () => {
|
||||
await execTool(makeTool({ agentChannel: undefined }), makePatchParams());
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses deliveryContext when agentChannel is an empty string", async () => {
|
||||
await execTool(makeTool({ agentChannel: "" }), makePatchParams());
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to server extractDeliveryInfo when live context is suppressed", async () => {
|
||||
// Confirm the RPC call still goes through — server side will use extractDeliveryInfo
|
||||
await execTool(makeTool({ agentTo: undefined }), makePatchParams());
|
||||
|
||||
expect(mocks.callGatewayTool).toHaveBeenCalled();
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Stale heartbeat override prevention ─────────────────────────────────
|
||||
|
||||
it("overrides stale heartbeat deliveryContext from extractDeliveryInfo with live context", async () => {
|
||||
// extractDeliveryInfo returning heartbeat sink — must not win over live context
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "webchat", to: "heartbeat", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
await execTool(makeTool(), makePatchParams());
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("discord");
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.to).toBe("123456789");
|
||||
});
|
||||
|
||||
// ── Session key targeting: same-session ─────────────────────────────────
|
||||
|
||||
it("forwards live context when sessionKey matches own session key exactly", async () => {
|
||||
await execTool(
|
||||
makeTool({ agentSessionKey: "agent:main:main" }),
|
||||
makePatchParams({ sessionKey: "agent:main:main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
});
|
||||
|
||||
it("forwards live context when 'main' alias resolves to own default-agent session", async () => {
|
||||
// agentSessionKey is "agent:main:main"; sessionKey "main" should canonicalize to the same
|
||||
await execTool(
|
||||
makeTool({ agentSessionKey: "agent:main:main" }),
|
||||
makePatchParams({ sessionKey: "main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("discord");
|
||||
});
|
||||
|
||||
it("forwards live context when sessionKey is omitted (defaults to own session)", async () => {
|
||||
await execTool(makeTool(), makePatchParams({ sessionKey: undefined }));
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Session key targeting: cross-session / cross-agent ───────────────────
|
||||
|
||||
it("suppresses deliveryContext when sessionKey targets a different session", async () => {
|
||||
await execTool(
|
||||
makeTool({ agentSessionKey: "agent:main:main" }),
|
||||
makePatchParams({ sessionKey: "agent:other-claw:main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses deliveryContext when non-default agent passes sessionKey='main' (cross-agent alias)", async () => {
|
||||
// "main" resolves to "agent:main:main" (default), not "agent:shopping-claw:main"
|
||||
await execTool(
|
||||
makeTool({ agentSessionKey: "agent:shopping-claw:main" }),
|
||||
makePatchParams({ sessionKey: "main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Remote gateway targeting ─────────────────────────────────────────────
|
||||
|
||||
it("suppresses deliveryContext when resolveGatewayTarget returns 'remote' (explicit URL)", async () => {
|
||||
mocks.readGatewayCallOptions.mockReturnValueOnce({ gatewayUrl: "wss://remote.example.com" });
|
||||
mocks.resolveGatewayTarget.mockReturnValueOnce("remote");
|
||||
|
||||
await execTool(
|
||||
makeTool(),
|
||||
makePatchParams({ gatewayUrl: "wss://remote.example.com", sessionKey: "agent:main:main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses deliveryContext when resolveGatewayTarget returns 'remote' (config gateway.mode=remote)", async () => {
|
||||
mocks.readGatewayCallOptions.mockReturnValueOnce({});
|
||||
mocks.resolveGatewayTarget.mockReturnValueOnce("remote");
|
||||
|
||||
await execTool(makeTool(), makePatchParams({ sessionKey: "agent:main:main" }));
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards deliveryContext when resolveGatewayTarget returns 'local' (loopback URL)", async () => {
|
||||
mocks.readGatewayCallOptions.mockReturnValueOnce({ gatewayUrl: "ws://127.0.0.1:18789" });
|
||||
mocks.resolveGatewayTarget.mockReturnValueOnce("local");
|
||||
|
||||
await execTool(
|
||||
makeTool(),
|
||||
makePatchParams({ gatewayUrl: "ws://127.0.0.1:18789", sessionKey: "agent:main:main" }),
|
||||
);
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
expect((p?.deliveryContext as Record<string, unknown>)?.channel).toBe("discord");
|
||||
});
|
||||
|
||||
it("forwards deliveryContext when resolveGatewayTarget returns undefined (default local)", async () => {
|
||||
mocks.resolveGatewayTarget.mockReturnValueOnce(undefined);
|
||||
|
||||
await execTool(makeTool(), makePatchParams({ sessionKey: "agent:main:main" }));
|
||||
|
||||
const p = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
|
||||
expect(p?.deliveryContext).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 2 — Restart sentinel context (local restart action)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createGatewayTool – restart sentinel delivery context", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses live context when both agentChannel and agentTo are present", async () => {
|
||||
await execTool(makeTool(), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("discord");
|
||||
expect(p?.deliveryContext?.to).toBe("123456789");
|
||||
});
|
||||
|
||||
it("falls back to extractDeliveryInfo when agentTo is missing", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "telegram", to: "+19995550001", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
await execTool(makeTool({ agentTo: undefined }), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("telegram");
|
||||
expect(p?.deliveryContext?.to).toBe("+19995550001");
|
||||
});
|
||||
|
||||
it("falls back to extractDeliveryInfo when agentChannel is missing", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "whatsapp", to: "+10000000001", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
await execTool(makeTool({ agentChannel: undefined }), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("whatsapp");
|
||||
});
|
||||
|
||||
it("overrides stale heartbeat context from extractDeliveryInfo with live context", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "webchat", to: "heartbeat", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
await execTool(makeTool(), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("discord");
|
||||
expect(p?.deliveryContext?.to).toBe("123456789");
|
||||
});
|
||||
|
||||
it("includes threadId in sentinel when agentThreadId is provided (same session)", async () => {
|
||||
await execTool(makeTool({ agentThreadId: "ts.123456" }), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ threadId?: string }>(mocks.writeRestartSentinel, 0, 0);
|
||||
expect(p?.threadId).toBe("ts.123456");
|
||||
});
|
||||
|
||||
it("uses extractDeliveryInfo threadId when targeting a different session", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "telegram", to: "+19995550001", accountId: undefined },
|
||||
threadId: "extracted-thread",
|
||||
});
|
||||
|
||||
await execTool(makeTool({ agentThreadId: "local-thread" }), {
|
||||
action: "restart",
|
||||
sessionKey: "agent:other-claw:main",
|
||||
});
|
||||
|
||||
const p = getCallArg<{ threadId?: string }>(mocks.writeRestartSentinel, 0, 0);
|
||||
expect(p?.threadId).toBe("extracted-thread");
|
||||
});
|
||||
|
||||
it("suppresses live context and uses extractDeliveryInfo when sessionKey targets another session", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "signal", to: "+15550001", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
await execTool(makeTool(), { action: "restart", sessionKey: "agent:other-agent:main" });
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("signal");
|
||||
expect(p?.deliveryContext?.to).toBe("+15550001");
|
||||
});
|
||||
|
||||
it("suppresses live context when non-default agent targets sessionKey='main' (cross-agent alias)", async () => {
|
||||
mocks.extractDeliveryInfo.mockReturnValueOnce({
|
||||
deliveryContext: { channel: "telegram", to: "+19995550001", accountId: undefined },
|
||||
threadId: undefined,
|
||||
});
|
||||
|
||||
await execTool(makeTool({ agentSessionKey: "agent:shopping-claw:main" }), {
|
||||
action: "restart",
|
||||
sessionKey: "main", // resolves to "agent:main:main" — different agent
|
||||
});
|
||||
|
||||
const p = getCallArg<{ deliveryContext?: Record<string, unknown> }>(
|
||||
mocks.writeRestartSentinel,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(p?.deliveryContext?.channel).toBe("telegram");
|
||||
expect(p?.deliveryContext?.to).toBe("+19995550001");
|
||||
});
|
||||
|
||||
it("sets status=ok and kind=restart on the sentinel payload", async () => {
|
||||
await execTool(makeTool(), { action: "restart" });
|
||||
|
||||
const p = getCallArg<{ kind?: string; status?: string }>(mocks.writeRestartSentinel, 0, 0);
|
||||
expect(p?.kind).toBe("restart");
|
||||
expect(p?.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("includes sessionKey in sentinel payload", async () => {
|
||||
await execTool(makeTool({ agentSessionKey: "agent:main:main" }), {
|
||||
action: "restart",
|
||||
});
|
||||
|
||||
const p = getCallArg<{ sessionKey?: string }>(mocks.writeRestartSentinel, 0, 0);
|
||||
expect(p?.sessionKey).toBe("agent:main:main");
|
||||
});
|
||||
});
|
||||
@ -12,7 +12,7 @@ import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { stringEnum } from "../schema/typebox.js";
|
||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||
import { callGatewayTool, readGatewayCallOptions } from "./gateway.js";
|
||||
import { callGatewayTool, readGatewayCallOptions, resolveGatewayTarget } from "./gateway.js";
|
||||
|
||||
const log = createSubsystemLogger("gateway-tool");
|
||||
|
||||
@ -69,6 +69,10 @@ const GatewayToolSchema = Type.Object({
|
||||
|
||||
export function createGatewayTool(opts?: {
|
||||
agentSessionKey?: string;
|
||||
agentChannel?: string;
|
||||
agentTo?: string;
|
||||
agentThreadId?: string | number;
|
||||
agentAccountId?: string;
|
||||
config?: OpenClawConfig;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
@ -76,7 +80,7 @@ export function createGatewayTool(opts?: {
|
||||
name: "gateway",
|
||||
ownerOnly: true,
|
||||
description:
|
||||
"Restart, inspect a specific config schema path, apply config, or update the gateway in-place (SIGUSR1). Use config.schema.lookup with a targeted dot path before config edits. Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing. Always pass a human-readable completion message via the `note` parameter so the system can deliver it to the user after restart.",
|
||||
"Restart, inspect a specific config schema path, apply config, or update the gateway in-place (SIGUSR1). Use config.schema.lookup with a targeted dot path before config edits. Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing. Always pass a human-readable completion message via the `note` parameter so the system can deliver it to the user after restart. IMPORTANT: Never use the `openclaw gateway restart` CLI command to restart — it bypasses the restart sentinel so the agent will not auto-resume or notify the user after restart. Always restart via this tool (action=restart) or via config.patch/config.apply, which write the sentinel before restarting. Config keys under gateway.*, discovery.*, plugins.*, and canvasHost.* trigger a real process restart; keys under messages.*, agents.*, tools.*, hooks.*, and most others apply dynamically without a restart.",
|
||||
parameters: GatewayToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
@ -85,10 +89,11 @@ export function createGatewayTool(opts?: {
|
||||
if (!isRestartEnabled(opts?.config)) {
|
||||
throw new Error("Gateway restart is disabled (commands.restart=false).");
|
||||
}
|
||||
const sessionKey =
|
||||
const explicitSessionKey =
|
||||
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
||||
? params.sessionKey.trim()
|
||||
: opts?.agentSessionKey?.trim() || undefined;
|
||||
: undefined;
|
||||
const sessionKey = (explicitSessionKey ?? opts?.agentSessionKey?.trim()) || undefined;
|
||||
const delayMs =
|
||||
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
|
||||
? Math.floor(params.delayMs)
|
||||
@ -99,9 +104,74 @@ export function createGatewayTool(opts?: {
|
||||
: undefined;
|
||||
const note =
|
||||
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
|
||||
// Extract channel + threadId for routing after restart
|
||||
// Supports both :thread: (most channels) and :topic: (Telegram)
|
||||
const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey);
|
||||
// Prefer the live delivery context captured during the current agent
|
||||
// run over extractDeliveryInfo() (which reads the persisted session
|
||||
// store). The session store is frequently overwritten by heartbeat
|
||||
// runs to { channel: "webchat", to: "heartbeat" }, causing the
|
||||
// sentinel to write stale routing data that fails post-restart.
|
||||
// See #18612.
|
||||
//
|
||||
// Only apply the live context when the restart targets this agent's
|
||||
// own session. When an explicit sessionKey points to a different
|
||||
// session, the live context belongs to the wrong session and would
|
||||
// misroute the post-restart reply. Fall back to extractDeliveryInfo()
|
||||
// so the server uses the correct routing for the target session.
|
||||
// Canonicalize both keys before comparing so that aliases like "main"
|
||||
// and "agent:main:main" are treated as the same session. Without this,
|
||||
// an operator passing sessionKey="main" would be incorrectly treated as
|
||||
// targeting a different session, suppressing live deliveryContext and
|
||||
// falling back to the stale session store. See #18612.
|
||||
const ownKey = opts?.agentSessionKey?.trim() || undefined;
|
||||
const agentId = resolveAgentIdFromSessionKey(ownKey);
|
||||
// Canonicalize each key using its OWN agentId — not the current session's.
|
||||
// If a non-default agent passes sessionKey="main", resolveAgentIdFromSessionKey
|
||||
// returns DEFAULT_AGENT_ID ("main") so "main" → "agent:main:main". Using the
|
||||
// current session's agentId instead would map "main" to the current agent's main
|
||||
// session, falsely treating a cross-agent request as same-session. See #18612.
|
||||
const canonicalizeOwn = (k: string) =>
|
||||
canonicalizeMainSessionAlias({ cfg: opts?.config, agentId, sessionKey: k });
|
||||
const canonicalizeTarget = (k: string) =>
|
||||
canonicalizeMainSessionAlias({
|
||||
cfg: opts?.config,
|
||||
agentId: resolveAgentIdFromSessionKey(k),
|
||||
sessionKey: k,
|
||||
});
|
||||
const isTargetingOtherSession =
|
||||
explicitSessionKey != null &&
|
||||
canonicalizeTarget(explicitSessionKey) !== (ownKey ? canonicalizeOwn(ownKey) : undefined);
|
||||
// Only forward live context when both channel and to are present.
|
||||
// Forwarding a partial context (channel without to) causes the server
|
||||
// to write a sentinel without `to`, and scheduleRestartSentinelWake
|
||||
// bails on `if (!channel || !to)`, silently degrading to a system
|
||||
// event with no delivery/resume. See #18612.
|
||||
const liveContext =
|
||||
!isTargetingOtherSession &&
|
||||
opts?.agentChannel != null &&
|
||||
String(opts.agentChannel).trim() &&
|
||||
opts?.agentTo != null &&
|
||||
String(opts.agentTo).trim()
|
||||
? {
|
||||
channel: String(opts.agentChannel).trim(),
|
||||
to: String(opts.agentTo).trim(),
|
||||
accountId: opts?.agentAccountId ?? undefined,
|
||||
}
|
||||
: undefined;
|
||||
const extracted = extractDeliveryInfo(sessionKey);
|
||||
const deliveryContext =
|
||||
liveContext != null
|
||||
? {
|
||||
...liveContext,
|
||||
accountId: liveContext.accountId ?? extracted.deliveryContext?.accountId,
|
||||
}
|
||||
: extracted.deliveryContext;
|
||||
// Guard threadId with the same session check as deliveryContext. When
|
||||
// targeting another session, opts.agentThreadId belongs to the current
|
||||
// session's thread and must not be written into the sentinel — it would
|
||||
// cause scheduleRestartSentinelWake to deliver to the wrong thread.
|
||||
const threadId =
|
||||
!isTargetingOtherSession && opts?.agentThreadId != null
|
||||
? String(opts.agentThreadId)
|
||||
: extracted.threadId;
|
||||
const payload: RestartSentinelPayload = {
|
||||
kind: "restart",
|
||||
status: "ok",
|
||||
@ -133,22 +203,92 @@ export function createGatewayTool(opts?: {
|
||||
|
||||
const gatewayOpts = readGatewayCallOptions(params);
|
||||
|
||||
// Build the live delivery context from the current agent run's routing
|
||||
// fields. This is passed to server-side handlers so they can write an
|
||||
// accurate sentinel without reading the (potentially stale) session
|
||||
// store. The store is frequently overwritten by heartbeat runs to
|
||||
// { channel: "webchat", to: "heartbeat" }. See #18612.
|
||||
//
|
||||
// Note: agentThreadId is intentionally excluded here. threadId is
|
||||
// reliably derived server-side from the session key (via
|
||||
// parseSessionThreadInfo), which encodes it as :thread:N or :topic:N.
|
||||
// That parsing is not subject to heartbeat contamination, so there is
|
||||
// no need to forward it through the RPC params.
|
||||
// Only forward live context when both channel and to are present.
|
||||
// Forwarding a partial context (channel without to) causes the server
|
||||
// to prefer an incomplete deliveryContext over extractDeliveryInfo(),
|
||||
// writing a sentinel without `to` that scheduleRestartSentinelWake
|
||||
// rejects, silently degrading to a system event. See #18612.
|
||||
//
|
||||
// threadId is included so the server can use it for sessions where the
|
||||
// session key is not :thread:-scoped (e.g. Slack replyToMode="all"), in
|
||||
// which case the session-key-derived threadId would be empty.
|
||||
const liveDeliveryContextForRpc =
|
||||
opts?.agentChannel != null &&
|
||||
String(opts.agentChannel).trim() &&
|
||||
opts?.agentTo != null &&
|
||||
String(opts.agentTo).trim()
|
||||
? {
|
||||
channel: String(opts.agentChannel).trim(),
|
||||
to: String(opts.agentTo).trim(),
|
||||
accountId: opts?.agentAccountId ?? undefined,
|
||||
threadId: opts?.agentThreadId != null ? String(opts.agentThreadId) : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const resolveGatewayWriteMeta = (): {
|
||||
sessionKey: string | undefined;
|
||||
note: string | undefined;
|
||||
restartDelayMs: number | undefined;
|
||||
deliveryContext: typeof liveDeliveryContextForRpc;
|
||||
} => {
|
||||
const sessionKey =
|
||||
const explicitSessionKey =
|
||||
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
||||
? params.sessionKey.trim()
|
||||
: opts?.agentSessionKey?.trim() || undefined;
|
||||
: undefined;
|
||||
const sessionKey = (explicitSessionKey ?? opts?.agentSessionKey?.trim()) || undefined;
|
||||
const note =
|
||||
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
|
||||
const restartDelayMs =
|
||||
typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs)
|
||||
? Math.floor(params.restartDelayMs)
|
||||
: undefined;
|
||||
return { sessionKey, note, restartDelayMs };
|
||||
// Only forward live context when the target session is this agent's
|
||||
// own session. Canonicalize both keys before comparing so that aliases
|
||||
// like "main" and "agent:main:main" are treated as the same session.
|
||||
// When an explicit sessionKey points to a different session, omit
|
||||
// deliveryContext so the server falls back to extractDeliveryInfo(sessionKey).
|
||||
const rpcOwnKey = opts?.agentSessionKey?.trim() || undefined;
|
||||
const rpcAgentId = resolveAgentIdFromSessionKey(rpcOwnKey);
|
||||
// Same cross-agent alias fix as the restart path: derive agentId from each key
|
||||
// independently so that "main" resolves to the default agent, not the current one.
|
||||
const rpcCanonicalizeOwn = (k: string) =>
|
||||
canonicalizeMainSessionAlias({ cfg: opts?.config, agentId: rpcAgentId, sessionKey: k });
|
||||
const rpcCanonicalizeTarget = (k: string) =>
|
||||
canonicalizeMainSessionAlias({
|
||||
cfg: opts?.config,
|
||||
agentId: resolveAgentIdFromSessionKey(k),
|
||||
sessionKey: k,
|
||||
});
|
||||
const isTargetingOtherSession =
|
||||
explicitSessionKey != null &&
|
||||
rpcCanonicalizeTarget(explicitSessionKey) !==
|
||||
(rpcOwnKey ? rpcCanonicalizeOwn(rpcOwnKey) : undefined);
|
||||
// Also omit when the call targets a remote gateway. The remote server's
|
||||
// extractDeliveryInfo(sessionKey) is the authoritative source for that
|
||||
// session's delivery route. Forwarding the local agent run's deliveryContext
|
||||
// would write a sentinel with the wrong chat destination on the remote host,
|
||||
// causing post-restart wake messages to be sent to the caller's chat instead
|
||||
// of the session on the remote gateway. See #18612.
|
||||
// Only suppress deliveryContext for truly remote gateways. A gatewayUrl
|
||||
// override pointing to a local loopback address (127.0.0.1, localhost,
|
||||
// [::1]) is still the local server and should forward context normally;
|
||||
// treating it as remote would fall back to extractDeliveryInfo(sessionKey)
|
||||
// and reintroduce the stale heartbeat routing this patch was meant to fix.
|
||||
const isRemoteGateway = resolveGatewayTarget(gatewayOpts) === "remote";
|
||||
const deliveryContext =
|
||||
isTargetingOtherSession || isRemoteGateway ? undefined : liveDeliveryContextForRpc;
|
||||
return { sessionKey, note, restartDelayMs, deliveryContext };
|
||||
};
|
||||
|
||||
const resolveConfigWriteParams = async (): Promise<{
|
||||
@ -157,6 +297,7 @@ export function createGatewayTool(opts?: {
|
||||
sessionKey: string | undefined;
|
||||
note: string | undefined;
|
||||
restartDelayMs: number | undefined;
|
||||
deliveryContext: typeof liveDeliveryContextForRpc;
|
||||
}> => {
|
||||
const raw = readStringParam(params, "raw", { required: true });
|
||||
let baseHash = readStringParam(params, "baseHash");
|
||||
@ -183,7 +324,7 @@ export function createGatewayTool(opts?: {
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
if (action === "config.apply") {
|
||||
const { raw, baseHash, sessionKey, note, restartDelayMs } =
|
||||
const { raw, baseHash, sessionKey, note, restartDelayMs, deliveryContext } =
|
||||
await resolveConfigWriteParams();
|
||||
const result = await callGatewayTool("config.apply", gatewayOpts, {
|
||||
raw,
|
||||
@ -191,11 +332,12 @@ export function createGatewayTool(opts?: {
|
||||
sessionKey,
|
||||
note,
|
||||
restartDelayMs,
|
||||
deliveryContext,
|
||||
});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
if (action === "config.patch") {
|
||||
const { raw, baseHash, sessionKey, note, restartDelayMs } =
|
||||
const { raw, baseHash, sessionKey, note, restartDelayMs, deliveryContext } =
|
||||
await resolveConfigWriteParams();
|
||||
const result = await callGatewayTool("config.patch", gatewayOpts, {
|
||||
raw,
|
||||
@ -203,11 +345,12 @@ export function createGatewayTool(opts?: {
|
||||
sessionKey,
|
||||
note,
|
||||
restartDelayMs,
|
||||
deliveryContext,
|
||||
});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
if (action === "update.run") {
|
||||
const { sessionKey, note, restartDelayMs } = resolveGatewayWriteMeta();
|
||||
const { sessionKey, note, restartDelayMs, deliveryContext } = resolveGatewayWriteMeta();
|
||||
const updateTimeoutMs = gatewayOpts.timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS;
|
||||
const updateGatewayOpts = {
|
||||
...gatewayOpts,
|
||||
@ -217,6 +360,7 @@ export function createGatewayTool(opts?: {
|
||||
sessionKey,
|
||||
note,
|
||||
restartDelayMs,
|
||||
deliveryContext,
|
||||
timeoutMs: updateTimeoutMs,
|
||||
});
|
||||
return jsonResult({ ok: true, result });
|
||||
|
||||
@ -1,189 +1,267 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { callGatewayTool, resolveGatewayOptions } from "./gateway.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
const configState = vi.hoisted(() => ({
|
||||
value: {} as Record<string, unknown>,
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mocks
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
resolveGatewayPort: vi.fn(() => 18789),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => configState.value,
|
||||
resolveGatewayPort: () => 18789,
|
||||
loadConfig: mocks.loadConfig,
|
||||
resolveGatewayPort: mocks.resolveGatewayPort,
|
||||
}));
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (...args: unknown[]) => callGatewayMock(...args),
|
||||
vi.mock("../../gateway/call.js", () => ({}));
|
||||
vi.mock("../../gateway/credentials.js", () => ({
|
||||
resolveGatewayCredentialsFromConfig: vi.fn(),
|
||||
trimToUndefined: (v: unknown) =>
|
||||
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined,
|
||||
}));
|
||||
vi.mock("../../gateway/method-scopes.js", () => ({
|
||||
resolveLeastPrivilegeOperatorScopesForMethod: vi.fn(() => []),
|
||||
}));
|
||||
vi.mock("../../utils/message-channel.js", () => ({
|
||||
GATEWAY_CLIENT_MODES: { BACKEND: "backend" },
|
||||
GATEWAY_CLIENT_NAMES: { GATEWAY_CLIENT: "gateway-client" },
|
||||
}));
|
||||
vi.mock("./common.js", () => ({
|
||||
readStringParam: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("gateway tool defaults", () => {
|
||||
const envSnapshot = {
|
||||
openclaw: process.env.OPENCLAW_GATEWAY_TOKEN,
|
||||
clawdbot: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
};
|
||||
const { resolveGatewayTarget } = await import("./gateway.js");
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function setConfig(overrides: Record<string, unknown>) {
|
||||
mocks.loadConfig.mockReturnValue(overrides);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite: resolveGatewayTarget — env URL overrides and remote-mode fallback
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveGatewayTarget – env URL override classification", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockClear();
|
||||
configState.value = {};
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
vi.clearAllMocks();
|
||||
setConfig({});
|
||||
delete process.env.OPENCLAW_GATEWAY_URL;
|
||||
delete process.env.CLAWDBOT_GATEWAY_URL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (envSnapshot.openclaw === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = envSnapshot.openclaw;
|
||||
}
|
||||
if (envSnapshot.clawdbot === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = envSnapshot.clawdbot;
|
||||
}
|
||||
afterEach(() => {
|
||||
delete process.env.OPENCLAW_GATEWAY_URL;
|
||||
delete process.env.CLAWDBOT_GATEWAY_URL;
|
||||
});
|
||||
|
||||
it("leaves url undefined so callGateway can use config", () => {
|
||||
const opts = resolveGatewayOptions();
|
||||
expect(opts.url).toBeUndefined();
|
||||
it("returns undefined (local) with no overrides and default config", () => {
|
||||
expect(resolveGatewayTarget()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts allowlisted gatewayUrl overrides (SSRF hardening)", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
await callGatewayTool(
|
||||
"health",
|
||||
{ gatewayUrl: "ws://127.0.0.1:18789", gatewayToken: "t", timeoutMs: 5000 },
|
||||
{},
|
||||
);
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "t",
|
||||
timeoutMs: 5000,
|
||||
scopes: ["operator.read"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_GATEWAY_TOKEN for allowlisted local overrides", () => {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
|
||||
const opts = resolveGatewayOptions({ gatewayUrl: "ws://127.0.0.1:18789" });
|
||||
expect(opts.url).toBe("ws://127.0.0.1:18789");
|
||||
expect(opts.token).toBe("env-token");
|
||||
});
|
||||
|
||||
it("falls back to config gateway.auth.token when env is unset for local overrides", () => {
|
||||
configState.value = {
|
||||
gateway: {
|
||||
auth: { token: "config-token" },
|
||||
},
|
||||
};
|
||||
const opts = resolveGatewayOptions({ gatewayUrl: "ws://127.0.0.1:18789" });
|
||||
expect(opts.token).toBe("config-token");
|
||||
});
|
||||
|
||||
it("uses gateway.remote.token for allowlisted remote overrides", () => {
|
||||
configState.value = {
|
||||
gateway: {
|
||||
remote: {
|
||||
url: "wss://gateway.example",
|
||||
token: "remote-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" });
|
||||
expect(opts.url).toBe("wss://gateway.example");
|
||||
expect(opts.token).toBe("remote-token");
|
||||
});
|
||||
|
||||
it("does not leak local env/config tokens to remote overrides", () => {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "local-env-token";
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "legacy-env-token";
|
||||
configState.value = {
|
||||
gateway: {
|
||||
auth: { token: "local-config-token" },
|
||||
remote: {
|
||||
url: "wss://gateway.example",
|
||||
},
|
||||
},
|
||||
};
|
||||
const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" });
|
||||
expect(opts.token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores unresolved local token SecretRef for strict remote overrides", () => {
|
||||
configState.value = {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
|
||||
},
|
||||
remote: {
|
||||
url: "wss://gateway.example",
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" });
|
||||
expect(opts.token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("explicit gatewayToken overrides fallback token resolution", () => {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "local-env-token";
|
||||
configState.value = {
|
||||
gateway: {
|
||||
remote: {
|
||||
url: "wss://gateway.example",
|
||||
token: "remote-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
const opts = resolveGatewayOptions({
|
||||
gatewayUrl: "wss://gateway.example",
|
||||
gatewayToken: "explicit-token",
|
||||
it("returns 'remote' when gateway.mode=remote AND gateway.remote.url is set", () => {
|
||||
setConfig({
|
||||
gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } },
|
||||
});
|
||||
expect(opts.token).toBe("explicit-token");
|
||||
expect(resolveGatewayTarget()).toBe("remote");
|
||||
});
|
||||
|
||||
it("uses least-privilege write scope for write methods", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
await callGatewayTool("wake", {}, { mode: "now", text: "hi" });
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "wake",
|
||||
scopes: ["operator.write"],
|
||||
}),
|
||||
);
|
||||
it("returns undefined when gateway.mode=remote but gateway.remote.url is missing (callGateway falls back to local)", () => {
|
||||
// This was the key regression: mode=remote without a url falls back to loopback, but the
|
||||
// old code returned "remote", causing deliveryContext to be suppressed for a local call.
|
||||
setConfig({ gateway: { mode: "remote" } });
|
||||
expect(resolveGatewayTarget()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses admin scope only for admin methods", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
await callGatewayTool("cron.add", {}, { id: "job-1" });
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "cron.add",
|
||||
scopes: ["operator.admin"],
|
||||
}),
|
||||
);
|
||||
it("returns undefined (local) when gateway.mode=remote with a loopback remote.url (no tunnel evidence)", () => {
|
||||
// A configured loopback remote.url (e.g. ws://127.0.0.1:18789) is indistinguishable
|
||||
// from a local gateway on a custom port. Without a non-loopback URL proving SSH tunnel
|
||||
// usage, classify as local so deliveryContext is preserved and post-restart wake
|
||||
// messages are not misrouted via stale extractDeliveryInfo routing.
|
||||
setConfig({ gateway: { mode: "remote", remote: { url: "ws://127.0.0.1:18789" } } });
|
||||
expect(resolveGatewayTarget()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("default-denies unknown methods by sending no scopes", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
await callGatewayTool("nonexistent.method", {}, {});
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "nonexistent.method",
|
||||
scopes: [],
|
||||
}),
|
||||
);
|
||||
it("returns undefined when gateway.mode=remote but gateway.remote.url is empty string", () => {
|
||||
setConfig({ gateway: { mode: "remote", remote: { url: " " } } });
|
||||
expect(resolveGatewayTarget()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects non-allowlisted overrides (SSRF hardening)", async () => {
|
||||
await expect(
|
||||
callGatewayTool("health", { gatewayUrl: "ws://127.0.0.1:8080", gatewayToken: "t" }, {}),
|
||||
).rejects.toThrow(/gatewayUrl override rejected/i);
|
||||
await expect(
|
||||
callGatewayTool("health", { gatewayUrl: "ws://169.254.169.254", gatewayToken: "t" }, {}),
|
||||
).rejects.toThrow(/gatewayUrl override rejected/i);
|
||||
it("classifies OPENCLAW_GATEWAY_URL loopback env override as 'local' (no remote config)", () => {
|
||||
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||
setConfig({});
|
||||
expect(resolveGatewayTarget()).toBe("local");
|
||||
});
|
||||
|
||||
it("classifies CLAWDBOT_GATEWAY_URL loopback env override as 'local' (no remote config)", () => {
|
||||
process.env.CLAWDBOT_GATEWAY_URL = "ws://localhost:18789";
|
||||
setConfig({});
|
||||
expect(resolveGatewayTarget()).toBe("local");
|
||||
});
|
||||
|
||||
it("classifies loopback env URL as 'remote' when mode=remote with non-loopback remote URL (SSH tunnel, same port)", () => {
|
||||
// Common SSH tunnel pattern: ssh -N -L 18789:remote-host:18789
|
||||
// OPENCLAW_GATEWAY_URL points to the local tunnel endpoint but gateway is remote.
|
||||
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||
setConfig({
|
||||
gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } },
|
||||
});
|
||||
expect(resolveGatewayTarget()).toBe("remote");
|
||||
});
|
||||
|
||||
it("classifies loopback env URL with different port as 'remote' when mode=remote with non-loopback remote URL (SSH tunnel, different port)", () => {
|
||||
// SSH tunnel to a non-default port: ssh -N -L 9000:remote-host:9000
|
||||
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:9000";
|
||||
setConfig({
|
||||
gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } },
|
||||
});
|
||||
expect(resolveGatewayTarget()).toBe("remote");
|
||||
});
|
||||
|
||||
it("classifies loopback env URL as 'local' when mode=remote but remote.url is absent (callGateway falls back to local)", () => {
|
||||
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||
setConfig({ gateway: { mode: "remote" } });
|
||||
expect(resolveGatewayTarget()).toBe("local");
|
||||
});
|
||||
|
||||
it("classifies loopback env URL on non-local port as 'local' without remote config (local gateway on custom port)", () => {
|
||||
// ws://127.0.0.1:9000 with no non-loopback remote URL configured: cannot prove this is
|
||||
// an SSH tunnel — it may simply be a local gateway on a custom port. Preserve "local"
|
||||
// so deliveryContext is not suppressed and heartbeat wake-up routing stays correct.
|
||||
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:9000";
|
||||
setConfig({});
|
||||
expect(resolveGatewayTarget()).toBe("local");
|
||||
});
|
||||
|
||||
it("classifies loopback env URL on non-local port as 'local' when mode=remote but no remote.url (no tunnel evidence)", () => {
|
||||
// mode=remote without a non-loopback remote.url is insufficient to prove SSH tunnel;
|
||||
// treat as local gateway on custom port.
|
||||
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:9000";
|
||||
setConfig({ gateway: { mode: "remote" } });
|
||||
expect(resolveGatewayTarget()).toBe("local");
|
||||
});
|
||||
|
||||
it("classifies loopback env URL as 'local' when mode=remote but remote.url is a loopback address (local-only setup)", () => {
|
||||
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||
setConfig({ gateway: { mode: "remote", remote: { url: "ws://127.0.0.1:18789" } } });
|
||||
expect(resolveGatewayTarget()).toBe("local");
|
||||
});
|
||||
|
||||
it("classifies OPENCLAW_GATEWAY_URL matching gateway.remote.url as 'remote'", () => {
|
||||
process.env.OPENCLAW_GATEWAY_URL = "wss://remote.example.com";
|
||||
setConfig({
|
||||
gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } },
|
||||
});
|
||||
expect(resolveGatewayTarget()).toBe("remote");
|
||||
});
|
||||
|
||||
it("falls through to config-based resolution when OPENCLAW_GATEWAY_URL is rejected (malformed)", () => {
|
||||
process.env.OPENCLAW_GATEWAY_URL = "not-a-url";
|
||||
setConfig({
|
||||
gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } },
|
||||
});
|
||||
// Falls through to config check: mode=remote + remote.url present → "remote"
|
||||
expect(resolveGatewayTarget()).toBe("remote");
|
||||
});
|
||||
|
||||
it("classifies env-only remote URL (not matching gateway.remote.url) as 'remote'", () => {
|
||||
// callGateway uses the env URL as-is even when validateGatewayUrlOverrideForAgentTools
|
||||
// rejects it (different host than configured gateway.remote.url). Must not leak
|
||||
// deliveryContext into a remote call by falling back to 'local'.
|
||||
process.env.OPENCLAW_GATEWAY_URL = "wss://other-host.example.com";
|
||||
setConfig({
|
||||
gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } },
|
||||
});
|
||||
expect(resolveGatewayTarget()).toBe("remote");
|
||||
});
|
||||
|
||||
it("classifies env-only remote URL with no configured gateway.remote.url as 'remote'", () => {
|
||||
// callGateway picks up the env URL even when gateway.remote.url is absent.
|
||||
process.env.OPENCLAW_GATEWAY_URL = "wss://remote.example.com";
|
||||
setConfig({});
|
||||
expect(resolveGatewayTarget()).toBe("remote");
|
||||
});
|
||||
|
||||
it("classifies env URL with /ws path (rejected by allowlist) as 'remote'", () => {
|
||||
// URLs with non-root paths are rejected by validateGatewayUrlOverrideForAgentTools but
|
||||
// callGateway/buildGatewayConnectionDetails still use them verbatim. Classify correctly.
|
||||
process.env.OPENCLAW_GATEWAY_URL = "wss://remote.example.com/ws";
|
||||
setConfig({});
|
||||
expect(resolveGatewayTarget()).toBe("remote");
|
||||
});
|
||||
|
||||
it("classifies loopback env URL with /ws path (rejected by allowlist) as 'local'", () => {
|
||||
// Even with a non-root path, loopback targets remain local.
|
||||
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789/ws";
|
||||
setConfig({});
|
||||
expect(resolveGatewayTarget()).toBe("local");
|
||||
});
|
||||
|
||||
it("OPENCLAW_GATEWAY_URL takes precedence over env CLAWDBOT_GATEWAY_URL", () => {
|
||||
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||
process.env.CLAWDBOT_GATEWAY_URL = "wss://remote.example.com";
|
||||
setConfig({
|
||||
gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } },
|
||||
});
|
||||
// OPENCLAW_GATEWAY_URL wins (loopback); mode=remote with non-loopback remote.url
|
||||
// means this loopback is an SSH tunnel → "remote"
|
||||
expect(resolveGatewayTarget()).toBe("remote");
|
||||
});
|
||||
|
||||
it("OPENCLAW_GATEWAY_URL takes precedence over env CLAWDBOT_GATEWAY_URL (no remote config → 'local')", () => {
|
||||
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||
process.env.CLAWDBOT_GATEWAY_URL = "wss://remote.example.com";
|
||||
setConfig({});
|
||||
// OPENCLAW_GATEWAY_URL wins (loopback), no remote config → "local"
|
||||
expect(resolveGatewayTarget()).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGatewayTarget – explicit gatewayUrl override", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setConfig({});
|
||||
delete process.env.OPENCLAW_GATEWAY_URL;
|
||||
delete process.env.CLAWDBOT_GATEWAY_URL;
|
||||
});
|
||||
|
||||
it("returns 'local' for loopback explicit gatewayUrl (no remote config)", () => {
|
||||
expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:18789" })).toBe("local");
|
||||
});
|
||||
|
||||
it("returns 'remote' for explicit remote gatewayUrl matching configured remote URL", () => {
|
||||
setConfig({
|
||||
gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } },
|
||||
});
|
||||
expect(resolveGatewayTarget({ gatewayUrl: "wss://remote.example.com" })).toBe("remote");
|
||||
});
|
||||
|
||||
it("returns 'remote' for loopback explicit gatewayUrl when mode=remote with non-loopback remote URL (SSH tunnel)", () => {
|
||||
setConfig({
|
||||
gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } },
|
||||
});
|
||||
expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:18789" })).toBe("remote");
|
||||
});
|
||||
|
||||
it("returns 'local' for loopback explicit gatewayUrl on non-local port without remote config (local gateway on custom port)", () => {
|
||||
// ws://127.0.0.1:9000 with no non-loopback remote URL: cannot distinguish SSH tunnel
|
||||
// from local gateway on a custom port — preserve "local" so deliveryContext is kept.
|
||||
setConfig({});
|
||||
expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:9000" })).toBe("local");
|
||||
});
|
||||
|
||||
it("returns 'local' for loopback explicit gatewayUrl on non-local port when mode=remote but no remote.url (no tunnel evidence)", () => {
|
||||
// mode=remote without a non-loopback remote.url cannot prove SSH tunnel; treat as local.
|
||||
setConfig({ gateway: { mode: "remote" } });
|
||||
expect(resolveGatewayTarget({ gatewayUrl: "ws://localhost:9000" })).toBe("local");
|
||||
});
|
||||
|
||||
it("returns 'remote' for loopback explicit gatewayUrl on non-local port when mode=remote with non-loopback remote.url (SSH tunnel)", () => {
|
||||
// With a non-loopback remote URL configured, a custom-port loopback is unambiguously
|
||||
// an SSH tunnel endpoint — classify as "remote" to suppress deliveryContext.
|
||||
setConfig({ gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } } });
|
||||
expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:9000" })).toBe("remote");
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,7 +13,7 @@ export type GatewayCallOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type GatewayOverrideTarget = "local" | "remote";
|
||||
export type GatewayOverrideTarget = "local" | "remote";
|
||||
|
||||
export function readGatewayCallOptions(params: Record<string, unknown>): GatewayCallOptions {
|
||||
return {
|
||||
@ -53,6 +53,35 @@ function canonicalizeToolGatewayWsUrl(raw: string): { origin: string; key: strin
|
||||
return { origin, key };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when gateway.mode=remote is configured with a non-loopback remote URL.
|
||||
* This indicates the user is connecting to a remote gateway, possibly via SSH port forwarding
|
||||
* (ssh -N -L <local_port>:remote-host:<remote_port>). In that case, a loopback gatewayUrl
|
||||
* is a tunnel endpoint and should be classified as "remote" so deliveryContext is suppressed.
|
||||
*/
|
||||
function isNonLoopbackRemoteUrlConfigured(cfg: ReturnType<typeof loadConfig>): boolean {
|
||||
if (cfg.gateway?.mode !== "remote") {
|
||||
return false;
|
||||
}
|
||||
const remoteUrl =
|
||||
typeof cfg.gateway?.remote?.url === "string" ? cfg.gateway.remote.url.trim() : "";
|
||||
if (!remoteUrl) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(remoteUrl);
|
||||
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return !(host === "127.0.0.1" || host === "localhost" || host === "::1");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return h === "127.0.0.1" || h === "localhost" || h === "::1";
|
||||
}
|
||||
|
||||
function validateGatewayUrlOverrideForAgentTools(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
urlOverride: string;
|
||||
@ -82,11 +111,29 @@ function validateGatewayUrlOverrideForAgentTools(params: {
|
||||
|
||||
const parsed = canonicalizeToolGatewayWsUrl(params.urlOverride);
|
||||
if (localAllowed.has(parsed.key)) {
|
||||
return { url: parsed.origin, target: "local" };
|
||||
// A loopback URL on the configured port is normally the local gateway, but when
|
||||
// gateway.mode=remote is configured with a non-loopback remote URL, the user is
|
||||
// likely using SSH port forwarding (ssh -N -L ...) and this loopback is a tunnel
|
||||
// endpoint pointing to a remote gateway. Classify as "remote" so deliveryContext
|
||||
// is not forwarded to the remote server, which would misroute post-restart wake messages.
|
||||
const target = isNonLoopbackRemoteUrlConfigured(cfg) ? "remote" : "local";
|
||||
return { url: parsed.origin, target };
|
||||
}
|
||||
if (remoteKey && parsed.key === remoteKey) {
|
||||
return { url: parsed.origin, target: "remote" };
|
||||
}
|
||||
// Loopback URL on a non-configured port — could be either:
|
||||
// (a) An SSH tunnel endpoint (ssh -N -L <port>:remote-host:<remote-port>) → "remote"
|
||||
// (b) A local gateway running on a custom/non-default port → "local"
|
||||
// We can only distinguish (a) from (b) when a non-loopback remote URL is configured:
|
||||
// that proves gateway.mode=remote with an external host, so a loopback URL on any port
|
||||
// must be a forwarded tunnel. Without that evidence, treat the loopback as local so that
|
||||
// deliveryContext is not suppressed and heartbeat wake-up routing stays correct.
|
||||
const urlForTunnelCheck = new URL(params.urlOverride.trim()); // already validated above
|
||||
if (isLoopbackHostname(urlForTunnelCheck.hostname)) {
|
||||
const target = isNonLoopbackRemoteUrlConfigured(cfg) ? "remote" : "local";
|
||||
return { url: parsed.origin, target };
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
"gatewayUrl override rejected.",
|
||||
@ -113,6 +160,85 @@ function resolveGatewayOverrideToken(params: {
|
||||
}).token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves whether a GatewayCallOptions points to a local or remote gateway.
|
||||
* Returns "remote" when a remote gatewayUrl override is present, OR when
|
||||
* gateway.mode=remote is configured with a gateway.remote.url set.
|
||||
* Returns "local" for explicit loopback URL overrides (127.0.0.1, localhost, [::1])
|
||||
* UNLESS gateway.mode=remote is configured with a non-loopback remote URL, which indicates
|
||||
* the loopback is an SSH tunnel endpoint — in that case returns "remote".
|
||||
* Returns undefined when no override is present and the effective target is the local gateway
|
||||
* (including the gateway.mode=remote + missing gateway.remote.url fallback-to-local case).
|
||||
*
|
||||
* This mirrors the URL resolution path used by callGateway/buildGatewayConnectionDetails so
|
||||
* that deliveryContext suppression decisions are based on the actual connection target, not just
|
||||
* the configured mode. Mismatches fixed vs the previous version:
|
||||
* 1. gateway.mode=remote without gateway.remote.url: callGateway falls back to local loopback;
|
||||
* classifying that as "remote" would incorrectly suppress deliveryContext.
|
||||
* 2. Env URL overrides (OPENCLAW_GATEWAY_URL / CLAWDBOT_GATEWAY_URL) are picked up by
|
||||
* callGateway but were ignored here, causing incorrect local/remote classification.
|
||||
* 3. Tunneled loopback URLs (ssh -N -L ...) when gateway.mode=remote with a non-loopback
|
||||
* remote.url is configured: classifying as "local" would forward deliveryContext to the
|
||||
* remote server, causing post-restart wake messages to be misrouted to the caller's chat.
|
||||
* 4. Loopback URLs on a non-local port (ssh -N -L <port>:...) with local mode or no remote
|
||||
* URL configured: the non-local port cannot be the local gateway, so it must be a tunnel;
|
||||
* classifying as "local" would forward deliveryContext to the remote server (misrouting).
|
||||
*/
|
||||
export function resolveGatewayTarget(opts?: GatewayCallOptions): GatewayOverrideTarget | undefined {
|
||||
const cfg = loadConfig();
|
||||
if (trimToUndefined(opts?.gatewayUrl) === undefined) {
|
||||
// No explicit gatewayUrl param — mirror callGateway's resolution path.
|
||||
// Check env URL overrides first (same precedence as buildGatewayConnectionDetails).
|
||||
const envUrlOverride =
|
||||
trimToUndefined(process.env.OPENCLAW_GATEWAY_URL) ??
|
||||
trimToUndefined(process.env.CLAWDBOT_GATEWAY_URL);
|
||||
if (envUrlOverride !== undefined) {
|
||||
try {
|
||||
return validateGatewayUrlOverrideForAgentTools({
|
||||
cfg,
|
||||
urlOverride: envUrlOverride,
|
||||
}).target;
|
||||
} catch {
|
||||
// URL rejected by the agent-tools allowlist (e.g. non-loopback URL not matching
|
||||
// gateway.remote.url, or URL with a non-root path like /ws). callGateway /
|
||||
// buildGatewayConnectionDetails will still use this env URL as-is, so we must
|
||||
// classify based on the actual target host — not silently fall back to local.
|
||||
try {
|
||||
const parsed = new URL(envUrlOverride.trim());
|
||||
// Normalize IPv6 brackets: "[::1]" → "::1"
|
||||
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
const isLoopback = host === "127.0.0.1" || host === "localhost" || host === "::1";
|
||||
if (isLoopback) {
|
||||
// Classify as "remote" only when a non-loopback remote URL is configured,
|
||||
// which proves the loopback is an SSH tunnel endpoint
|
||||
// (ssh -N -L <local_port>:remote-host:<remote_port>). Without that evidence
|
||||
// a loopback URL on any port — including a non-default port — could be a
|
||||
// local gateway on a custom port, so we preserve "local" classification to
|
||||
// keep deliveryContext intact and avoid heartbeat-stale routing regressions.
|
||||
if (isNonLoopbackRemoteUrlConfigured(cfg)) {
|
||||
return "remote";
|
||||
}
|
||||
return "local";
|
||||
}
|
||||
return "remote";
|
||||
} catch {
|
||||
// Truly malformed URL; callGateway will also fail. Fall through to config-based resolution.
|
||||
}
|
||||
}
|
||||
}
|
||||
// No env override. Classify as "remote" only when mode=remote is configured with a
|
||||
// non-loopback remote URL. Loopback remote.url (e.g. ws://127.0.0.1:18789) is
|
||||
// indistinguishable from a local gateway on a custom port; without a non-loopback
|
||||
// URL proving SSH tunnel usage, treat it as local so deliveryContext is preserved
|
||||
// and post-restart wake messages are not misrouted.
|
||||
return isNonLoopbackRemoteUrlConfigured(cfg) ? "remote" : undefined;
|
||||
}
|
||||
return validateGatewayUrlOverrideForAgentTools({
|
||||
cfg,
|
||||
urlOverride: String(opts?.gatewayUrl),
|
||||
}).target;
|
||||
}
|
||||
|
||||
export function resolveGatewayOptions(opts?: GatewayCallOptions) {
|
||||
const cfg = loadConfig();
|
||||
const validatedOverride =
|
||||
|
||||
@ -17,6 +17,22 @@ export const ConfigSetParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
// deliveryContext carries the live channel/to/accountId from the agent run so
|
||||
// that the restart sentinel has accurate routing data even after heartbeats
|
||||
// have overwritten the session store with { channel: "webchat", to: "heartbeat" }.
|
||||
// Without this field, the additionalProperties: false constraint silently drops
|
||||
// it and the sentinel falls back to stale session store data. See #18612.
|
||||
const DeliveryContextSchema = Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
channel: Type.Optional(Type.String()),
|
||||
to: Type.Optional(Type.String()),
|
||||
accountId: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
);
|
||||
|
||||
const ConfigApplyLikeParamsSchema = Type.Object(
|
||||
{
|
||||
raw: NonEmptyString,
|
||||
@ -24,6 +40,7 @@ const ConfigApplyLikeParamsSchema = Type.Object(
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
note: Type.Optional(Type.String()),
|
||||
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
deliveryContext: DeliveryContextSchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@ -46,6 +63,7 @@ export const UpdateRunParamsSchema = Type.Object(
|
||||
note: Type.Optional(Type.String()),
|
||||
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
deliveryContext: DeliveryContextSchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@ -52,7 +52,7 @@ import {
|
||||
validateConfigSetParams,
|
||||
} from "../protocol/index.js";
|
||||
import { resolveBaseHashParam } from "./base-hash.js";
|
||||
import { parseRestartRequestParams } from "./restart-request.js";
|
||||
import { parseDeliveryContextFromParams, parseRestartRequestParams } from "./restart-request.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
@ -194,9 +194,25 @@ function resolveConfigRestartRequest(params: unknown): {
|
||||
} {
|
||||
const { sessionKey, note, restartDelayMs } = parseRestartRequestParams(params);
|
||||
|
||||
// Extract deliveryContext + threadId for routing after restart
|
||||
// Supports both :thread: (most channels) and :topic: (Telegram)
|
||||
const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey);
|
||||
// For deliveryContext, prefer the live context passed by the client over
|
||||
// extractDeliveryInfo(), which reads the persisted session store. Heartbeat
|
||||
// runs overwrite the store to { channel: "webchat", to: "heartbeat" }, so
|
||||
// reading it here would produce stale routing data. See #18612.
|
||||
//
|
||||
// For threadId, also prefer the client-forwarded value when present. The
|
||||
// session-key-derived threadId is empty for Slack sessions where replies are
|
||||
// threaded (replyToMode="all") but the session key is not :thread:-scoped.
|
||||
const { deliveryContext: extractedDeliveryContext, threadId: extractedThreadId } =
|
||||
extractDeliveryInfo(sessionKey);
|
||||
const paramsDeliveryContext = parseDeliveryContextFromParams(params);
|
||||
const deliveryContext =
|
||||
paramsDeliveryContext != null
|
||||
? {
|
||||
...paramsDeliveryContext,
|
||||
accountId: paramsDeliveryContext.accountId ?? extractedDeliveryContext?.accountId,
|
||||
}
|
||||
: extractedDeliveryContext;
|
||||
const threadId = paramsDeliveryContext?.threadId ?? extractedThreadId;
|
||||
|
||||
return {
|
||||
sessionKey,
|
||||
|
||||
207
src/gateway/server-methods/restart-request.test.ts
Normal file
207
src/gateway/server-methods/restart-request.test.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseDeliveryContextFromParams } from "./restart-request.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// parseDeliveryContextFromParams
|
||||
// Validates that only complete, routable delivery contexts are accepted
|
||||
// and that partial or malformed inputs are rejected.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseDeliveryContextFromParams", () => {
|
||||
// ── No context present ────────────────────────────────────────────────────
|
||||
|
||||
it("returns undefined when deliveryContext is absent", () => {
|
||||
expect(parseDeliveryContextFromParams({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when deliveryContext is null", () => {
|
||||
expect(parseDeliveryContextFromParams({ deliveryContext: null })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when deliveryContext is a non-object (string)", () => {
|
||||
expect(parseDeliveryContextFromParams({ deliveryContext: "discord" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when deliveryContext is a non-object (number)", () => {
|
||||
expect(parseDeliveryContextFromParams({ deliveryContext: 42 })).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Partial context — must be rejected (prevents routing ambiguity) ───────
|
||||
|
||||
it("returns undefined when both channel and to are absent", () => {
|
||||
expect(parseDeliveryContextFromParams({ deliveryContext: {} })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when only channel is present (partial context)", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: "discord" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when only to is present (partial context)", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { to: "123456789" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when channel is present but to is an empty string", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: "discord", to: "" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when to is present but channel is an empty string", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: "", to: "123456789" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when channel is whitespace-only", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: " ", to: "123456789" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when to is whitespace-only", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: "discord", to: " " } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Non-string field types ────────────────────────────────────────────────
|
||||
|
||||
it("returns undefined when channel is a number (type coercion not allowed)", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: 42, to: "123" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when to is a boolean", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({ deliveryContext: { channel: "discord", to: true } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Complete context ──────────────────────────────────────────────────────
|
||||
|
||||
it("returns full context when both channel and to are present", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789" },
|
||||
}),
|
||||
).toEqual({ channel: "discord", to: "123456789", accountId: undefined, threadId: undefined });
|
||||
});
|
||||
|
||||
it("includes accountId when present", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789", accountId: "acct-1" },
|
||||
});
|
||||
expect(result?.accountId).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("includes threadId when present", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "slack", to: "C012AB3CD", threadId: "1234567890.123456" },
|
||||
});
|
||||
expect(result?.threadId).toBe("1234567890.123456");
|
||||
});
|
||||
|
||||
it("includes all four fields when all are present", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({
|
||||
deliveryContext: {
|
||||
channel: "slack",
|
||||
to: "C012AB3CD",
|
||||
accountId: "acct-1",
|
||||
threadId: "1234567890.123456",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
channel: "slack",
|
||||
to: "C012AB3CD",
|
||||
accountId: "acct-1",
|
||||
threadId: "1234567890.123456",
|
||||
});
|
||||
});
|
||||
|
||||
// ── Whitespace trimming ───────────────────────────────────────────────────
|
||||
|
||||
it("trims leading/trailing whitespace from channel", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: " discord ", to: "123456789" },
|
||||
});
|
||||
expect(result?.channel).toBe("discord");
|
||||
});
|
||||
|
||||
it("trims leading/trailing whitespace from to", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: " 123456789 " },
|
||||
});
|
||||
expect(result?.to).toBe("123456789");
|
||||
});
|
||||
|
||||
it("trims leading/trailing whitespace from threadId", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123", threadId: " ts.1 " },
|
||||
});
|
||||
expect(result?.threadId).toBe("ts.1");
|
||||
});
|
||||
|
||||
it("trims all string fields simultaneously", () => {
|
||||
expect(
|
||||
parseDeliveryContextFromParams({
|
||||
deliveryContext: {
|
||||
channel: " discord ",
|
||||
to: " 123 ",
|
||||
accountId: " acct ",
|
||||
threadId: " ts.1 ",
|
||||
},
|
||||
}),
|
||||
).toEqual({ channel: "discord", to: "123", accountId: "acct", threadId: "ts.1" });
|
||||
});
|
||||
|
||||
// ── Optional fields absent / undefined ───────────────────────────────────
|
||||
|
||||
it("returns undefined for accountId when not provided", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789" },
|
||||
});
|
||||
expect(result?.accountId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for threadId when not provided", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789" },
|
||||
});
|
||||
expect(result?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for accountId when value is empty string after trim", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789", accountId: " " },
|
||||
});
|
||||
expect(result?.accountId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for threadId when value is empty string after trim", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789", threadId: " " },
|
||||
});
|
||||
expect(result?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Extra/unknown fields are ignored ─────────────────────────────────────
|
||||
|
||||
it("ignores unknown extra fields in deliveryContext", () => {
|
||||
const result = parseDeliveryContextFromParams({
|
||||
deliveryContext: { channel: "discord", to: "123456789", unknownField: "ignored" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
channel: "discord",
|
||||
to: "123456789",
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
});
|
||||
expect((result as Record<string, unknown>)?.unknownField).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -1,3 +1,42 @@
|
||||
/**
|
||||
* Parse the live deliveryContext passed by gateway-tool clients.
|
||||
*
|
||||
* Clients capture delivery context from the active agent run and forward it
|
||||
* so server-side handlers can write an accurate sentinel without reading the
|
||||
* persisted session store, which heartbeat runs frequently overwrite to
|
||||
* { channel: "webchat", to: "heartbeat" }. See #18612.
|
||||
*/
|
||||
export function parseDeliveryContextFromParams(
|
||||
params: unknown,
|
||||
): { channel?: string; to?: string; accountId?: string; threadId?: string } | undefined {
|
||||
const raw = (params as { deliveryContext?: unknown }).deliveryContext;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const channel =
|
||||
typeof (raw as { channel?: unknown }).channel === "string"
|
||||
? (raw as { channel: string }).channel.trim() || undefined
|
||||
: undefined;
|
||||
const to =
|
||||
typeof (raw as { to?: unknown }).to === "string"
|
||||
? (raw as { to: string }).to.trim() || undefined
|
||||
: undefined;
|
||||
const accountId =
|
||||
typeof (raw as { accountId?: unknown }).accountId === "string"
|
||||
? (raw as { accountId: string }).accountId.trim() || undefined
|
||||
: undefined;
|
||||
const threadId =
|
||||
typeof (raw as { threadId?: unknown }).threadId === "string"
|
||||
? (raw as { threadId: string }).threadId.trim() || undefined
|
||||
: undefined;
|
||||
// Require both channel and to — a partial context can overwrite a complete
|
||||
// extracted route and produce a non-routable sentinel. See #18612.
|
||||
if (!channel || !to) {
|
||||
return undefined;
|
||||
}
|
||||
return { channel, to, accountId, threadId };
|
||||
}
|
||||
|
||||
export function parseRestartRequestParams(params: unknown): {
|
||||
sessionKey: string | undefined;
|
||||
note: string | undefined;
|
||||
|
||||
@ -11,7 +11,7 @@ import { normalizeUpdateChannel } from "../../infra/update-channels.js";
|
||||
import { runGatewayUpdate } from "../../infra/update-runner.js";
|
||||
import { formatControlPlaneActor, resolveControlPlaneActor } from "../control-plane-audit.js";
|
||||
import { validateUpdateRunParams } from "../protocol/index.js";
|
||||
import { parseRestartRequestParams } from "./restart-request.js";
|
||||
import { parseDeliveryContextFromParams, parseRestartRequestParams } from "./restart-request.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
@ -22,7 +22,24 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
const actor = resolveControlPlaneActor(client);
|
||||
const { sessionKey, note, restartDelayMs } = parseRestartRequestParams(params);
|
||||
const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey);
|
||||
// Prefer live deliveryContext from params over extractDeliveryInfo() (see #18612).
|
||||
// Also prefer threadId from params when present — the session-key-derived value
|
||||
// is empty for Slack sessions where replyToMode="all" but the key is not :thread:-scoped.
|
||||
const { deliveryContext: extractedDeliveryContext, threadId: extractedThreadId } =
|
||||
extractDeliveryInfo(sessionKey);
|
||||
const paramsDeliveryContext = parseDeliveryContextFromParams(params);
|
||||
// When live channel/to is present but accountId is missing (e.g. /tools/invoke without
|
||||
// x-openclaw-account-id), fall back to the session-extracted account so the sentinel is
|
||||
// not written without account context — which would cause deliveries to use the channel
|
||||
// default account and misroute in multi-account setups. See config.ts for the same pattern.
|
||||
const deliveryContext =
|
||||
paramsDeliveryContext != null
|
||||
? {
|
||||
...paramsDeliveryContext,
|
||||
accountId: paramsDeliveryContext.accountId ?? extractedDeliveryContext?.accountId,
|
||||
}
|
||||
: extractedDeliveryContext;
|
||||
const threadId = paramsDeliveryContext?.threadId ?? extractedThreadId;
|
||||
const timeoutMsRaw = (params as { timeoutMs?: unknown }).timeoutMs;
|
||||
const timeoutMs =
|
||||
typeof timeoutMsRaw === "number" && Number.isFinite(timeoutMsRaw)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveSessionAgentId: vi.fn(() => "agent-from-key"),
|
||||
@ -13,6 +13,10 @@ const mocks = vi.hoisted(() => ({
|
||||
},
|
||||
})),
|
||||
formatRestartSentinelMessage: vi.fn(() => "restart message"),
|
||||
formatRestartSentinelUserMessage: vi.fn(() => "Gateway restarted successfully."),
|
||||
formatRestartSentinelInternalContext: vi.fn(
|
||||
() => "[Gateway restart context — internal]\nkind: restart\nstatus: ok",
|
||||
),
|
||||
summarizeRestartSentinel: vi.fn(() => "restart summary"),
|
||||
resolveMainSessionKeyFromConfig: vi.fn(() => "agent:main:main"),
|
||||
parseSessionThreadInfo: vi.fn(() => ({ baseSessionKey: null, threadId: undefined })),
|
||||
@ -25,8 +29,11 @@ const mocks = vi.hoisted(() => ({
|
||||
})),
|
||||
normalizeChannelId: vi.fn((channel: string) => channel),
|
||||
resolveOutboundTarget: vi.fn(() => ({ ok: true as const, to: "+15550002" })),
|
||||
deliverOutboundPayloads: vi.fn(async () => []),
|
||||
deliverOutboundPayloads: vi.fn(async () => undefined),
|
||||
buildOutboundSessionContext: vi.fn(() => ({ agentId: "main", sessionKey: "agent:main:main" })),
|
||||
agentCommand: vi.fn(async () => undefined),
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
defaultRuntime: {},
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
@ -36,6 +43,8 @@ vi.mock("../agents/agent-scope.js", () => ({
|
||||
vi.mock("../infra/restart-sentinel.js", () => ({
|
||||
consumeRestartSentinel: mocks.consumeRestartSentinel,
|
||||
formatRestartSentinelMessage: mocks.formatRestartSentinelMessage,
|
||||
formatRestartSentinelUserMessage: mocks.formatRestartSentinelUserMessage,
|
||||
formatRestartSentinelInternalContext: mocks.formatRestartSentinelInternalContext,
|
||||
summarizeRestartSentinel: mocks.summarizeRestartSentinel,
|
||||
}));
|
||||
|
||||
@ -72,23 +81,284 @@ vi.mock("../infra/outbound/deliver.js", () => ({
|
||||
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/outbound/session-context.js", () => ({
|
||||
buildOutboundSessionContext: mocks.buildOutboundSessionContext,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/agent.js", () => ({
|
||||
agentCommand: mocks.agentCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: mocks.defaultRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: mocks.enqueueSystemEvent,
|
||||
}));
|
||||
|
||||
const { scheduleRestartSentinelWake } = await import("./server-restart-sentinel.js");
|
||||
|
||||
describe("scheduleRestartSentinelWake", () => {
|
||||
it("forwards session context to outbound delivery", async () => {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 1 — Agent resume flow (no direct channel delivery)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleRestartSentinelWake – agent resume, no raw delivery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resumes agent with internal context and never delivers raw sentinel fields to channel", async () => {
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
// Raw sentinel fields must NEVER go directly to the channel — no deliverOutboundPayloads call.
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
|
||||
// Agent resume: summary as neutral wake prompt, full context in extraSystemPrompt only.
|
||||
expect(mocks.agentCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "whatsapp",
|
||||
message: "restart summary",
|
||||
extraSystemPrompt: expect.stringContaining("[Gateway restart context"),
|
||||
sessionKey: "agent:main:main",
|
||||
to: "+15550002",
|
||||
session: { key: "agent:main:main", agentId: "agent-from-key" },
|
||||
channel: "whatsapp",
|
||||
deliver: true,
|
||||
bestEffortDeliver: true,
|
||||
messageChannel: "whatsapp",
|
||||
accountId: "acct-2",
|
||||
}),
|
||||
mocks.defaultRuntime,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes senderIsOwner=false to agentCommand (no privilege escalation)", async () => {
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const opts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(opts.senderIsOwner).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops when there is no sentinel file", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce(null as never);
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
expect(mocks.agentCommand).not.toHaveBeenCalled();
|
||||
expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 2 — Fallback paths
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleRestartSentinelWake – fallback to enqueueSystemEvent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent on main session key when sentinel has no sessionKey", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({ payload: { sessionKey: "" } } as never);
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith("restart message", {
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
expect(mocks.agentCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent when outbound target cannot be resolved", async () => {
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({
|
||||
ok: false,
|
||||
error: new Error("no-target"),
|
||||
} as never);
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.agentCommand).not.toHaveBeenCalled();
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith("Gateway restarted successfully.", {
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent when channel is missing from merged delivery context", async () => {
|
||||
// mergeDeliveryContext is called twice (inner + outer merge); mock the outer to drop channel
|
||||
mocks.mergeDeliveryContext
|
||||
.mockReturnValueOnce(undefined as never) // inner: sessionDeliveryContext merge
|
||||
.mockReturnValueOnce({ to: "+15550002" } as never); // outer: sentinelContext wins, no channel
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.agentCommand).not.toHaveBeenCalled();
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith("Gateway restarted successfully.", {
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent when to is missing from merged delivery context", async () => {
|
||||
// Mock outer merge to return a context with no `to`
|
||||
mocks.mergeDeliveryContext
|
||||
.mockReturnValueOnce(undefined as never)
|
||||
.mockReturnValueOnce({ channel: "whatsapp" } as never);
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.agentCommand).not.toHaveBeenCalled();
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith("Gateway restarted successfully.", {
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to enqueueSystemEvent (with summary + error) when agentCommand throws", async () => {
|
||||
mocks.agentCommand.mockRejectedValueOnce(new Error("agent failed"));
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
// No direct delivery — never.
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
// Fallback enqueues summary + error so the user isn't left silent.
|
||||
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("restart summary"),
|
||||
{ sessionKey: "agent:main:main" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 3 — Thread routing (Slack vs non-Slack)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleRestartSentinelWake – thread routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes Slack threadId to agentCommand (Slack threading handled internally by agentCommand)", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
deliveryContext: { channel: "slack", to: "C012AB3CD", accountId: "acct-2" },
|
||||
threadId: "1234567890.123456",
|
||||
},
|
||||
} as never);
|
||||
mocks.normalizeChannelId.mockReturnValueOnce("slack");
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
// No direct delivery — agentCommand is the only delivery path.
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
const agentOpts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(agentOpts.threadId).toBe("1234567890.123456");
|
||||
});
|
||||
|
||||
it("passes threadId directly for non-Slack channels", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
deliveryContext: { channel: "discord", to: "123456789", accountId: "acct-2" },
|
||||
threadId: "discord-thread-id",
|
||||
},
|
||||
} as never);
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
const agentOpts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(agentOpts.threadId).toBe("discord-thread-id");
|
||||
});
|
||||
|
||||
it("passes threadId to agentCommand for non-Slack threading", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
deliveryContext: { channel: "discord", to: "123456789", accountId: "acct-2" },
|
||||
threadId: "discord-thread-id",
|
||||
},
|
||||
} as never);
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const agentOpts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(agentOpts.threadId).toBe("discord-thread-id");
|
||||
});
|
||||
|
||||
it("sentinel payload threadId takes precedence over session-derived threadId", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
deliveryContext: { channel: "whatsapp", to: "+15550002", accountId: "acct-2" },
|
||||
threadId: "sentinel-thread",
|
||||
},
|
||||
} as never);
|
||||
// parseSessionThreadInfo would derive a different threadId from the session key
|
||||
mocks.parseSessionThreadInfo.mockReturnValueOnce({
|
||||
baseSessionKey: null,
|
||||
threadId: "session-thread",
|
||||
} as never);
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const agentOpts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(agentOpts.threadId).toBe("sentinel-thread");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suite 4 — Delivery context priority: sentinel > session store > parsed target
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleRestartSentinelWake – delivery context priority", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("prefers sentinel deliveryContext over session store (handles heartbeat-overwritten store)", async () => {
|
||||
// Session store has been overwritten with heartbeat sink
|
||||
mocks.deliveryContextFromSession.mockReturnValueOnce({
|
||||
channel: "webchat",
|
||||
to: "heartbeat",
|
||||
} as never);
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
// agentCommand should use the sentinel's whatsapp/+15550002, not webchat/heartbeat
|
||||
const agentOpts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(agentOpts.channel).toBe("whatsapp");
|
||||
expect(agentOpts.to).toBe("+15550002");
|
||||
});
|
||||
|
||||
it("falls back to session store when sentinel has no deliveryContext", async () => {
|
||||
mocks.consumeRestartSentinel.mockResolvedValueOnce({
|
||||
payload: { sessionKey: "agent:main:main" }, // no deliveryContext
|
||||
} as never);
|
||||
mocks.deliveryContextFromSession.mockReturnValueOnce({
|
||||
channel: "telegram",
|
||||
to: "+19990001",
|
||||
} as never);
|
||||
// Mock both merge calls: inner produces session ctx; outer passes it through
|
||||
mocks.mergeDeliveryContext
|
||||
.mockReturnValueOnce({ channel: "telegram", to: "+19990001" } as never) // inner
|
||||
.mockReturnValueOnce({ channel: "telegram", to: "+19990001" } as never); // outer
|
||||
// resolveOutboundTarget must reflect the session-store to value
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({ ok: true as const, to: "+19990001" });
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
const agentOpts = getArg<Record<string, unknown>>(mocks.agentCommand, 0);
|
||||
expect(agentOpts.channel).toBe("telegram");
|
||||
expect(agentOpts.to).toBe("+19990001");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getArg<T>(mockFn: { mock: { calls: unknown[][] } }, argIdx: number): T {
|
||||
return mockFn.mock.calls[0]?.[argIdx] as T;
|
||||
}
|
||||
|
||||
@ -1,28 +1,35 @@
|
||||
import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
|
||||
import { parseSessionThreadInfo } from "../config/sessions/delivery-info.js";
|
||||
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
||||
import { buildOutboundSessionContext } from "../infra/outbound/session-context.js";
|
||||
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||
import {
|
||||
consumeRestartSentinel,
|
||||
formatRestartSentinelInternalContext,
|
||||
formatRestartSentinelMessage,
|
||||
formatRestartSentinelUserMessage,
|
||||
summarizeRestartSentinel,
|
||||
} from "../infra/restart-sentinel.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { deliveryContextFromSession, mergeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import { loadSessionEntry } from "./session-utils.js";
|
||||
|
||||
export async function scheduleRestartSentinelWake(_params: { deps: CliDeps }) {
|
||||
export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
||||
const sentinel = await consumeRestartSentinel();
|
||||
if (!sentinel) {
|
||||
return;
|
||||
}
|
||||
const payload = sentinel.payload;
|
||||
const sessionKey = payload.sessionKey?.trim();
|
||||
// Raw diagnostic message (used for system events and enqueue fallbacks).
|
||||
const message = formatRestartSentinelMessage(payload);
|
||||
// Human-friendly message for direct user delivery — omits status prefix and doctorHint.
|
||||
const userMessage = formatRestartSentinelUserMessage(payload);
|
||||
// Full technical context injected into the agent's system prompt.
|
||||
const internalContext = formatRestartSentinelInternalContext(payload);
|
||||
const summary = summarizeRestartSentinel(payload);
|
||||
|
||||
if (!sessionKey) {
|
||||
@ -54,7 +61,7 @@ export async function scheduleRestartSentinelWake(_params: { deps: CliDeps }) {
|
||||
const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
|
||||
const to = origin?.to;
|
||||
if (!channel || !to) {
|
||||
enqueueSystemEvent(message, { sessionKey });
|
||||
enqueueSystemEvent(userMessage, { sessionKey });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -66,7 +73,7 @@ export async function scheduleRestartSentinelWake(_params: { deps: CliDeps }) {
|
||||
mode: "implicit",
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
enqueueSystemEvent(message, { sessionKey });
|
||||
enqueueSystemEvent(userMessage, { sessionKey });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -76,31 +83,44 @@ export async function scheduleRestartSentinelWake(_params: { deps: CliDeps }) {
|
||||
sessionThreadId ??
|
||||
(origin?.threadId != null ? String(origin.threadId) : undefined);
|
||||
|
||||
// Slack uses replyToId (thread_ts) for threading, not threadId.
|
||||
// The reply path does this mapping but deliverOutboundPayloads does not,
|
||||
// so we must convert here to ensure post-restart notifications land in
|
||||
// the originating Slack thread. See #17716.
|
||||
const isSlack = channel === "slack";
|
||||
const replyToId = isSlack && threadId != null && threadId !== "" ? String(threadId) : undefined;
|
||||
const resolvedThreadId = isSlack ? undefined : threadId;
|
||||
const outboundSession = buildOutboundSessionContext({
|
||||
cfg,
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
// Trigger an agent resume turn so the agent can compose a natural response and
|
||||
// continue autonomously after restart. The restart context is injected via
|
||||
// extraSystemPrompt — the raw note, doctorHint, and status fields are NEVER
|
||||
// sent directly to the channel. Only the agent's composed reply reaches the user.
|
||||
//
|
||||
// summary is used as the neutral wake prompt (e.g. "Gateway restart config-patch ok").
|
||||
// It is an internal technical label; the agent sees it but users do not — only the
|
||||
// agent's response is delivered.
|
||||
//
|
||||
// This is safe post-restart: scheduleRestartSentinelWake() runs in the new process
|
||||
// with zero in-flight replies, so the pre-restart race condition (ab4a08a82) does
|
||||
// not apply here.
|
||||
//
|
||||
// Explicitly set senderIsOwner: false. The restart wake runs in a new process after
|
||||
// an operator-triggered restart, and we cannot reliably infer the original sender's
|
||||
// authorization level. Defaulting to false prevents privilege escalation where any
|
||||
// restarted session would inherit owner-level access. See #18612.
|
||||
try {
|
||||
await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel,
|
||||
to: resolved.to,
|
||||
accountId: origin?.accountId,
|
||||
replyToId,
|
||||
threadId: resolvedThreadId,
|
||||
payloads: [{ text: message }],
|
||||
session: outboundSession,
|
||||
bestEffort: true,
|
||||
});
|
||||
await agentCommand(
|
||||
{
|
||||
message: summary,
|
||||
extraSystemPrompt: internalContext,
|
||||
sessionKey,
|
||||
to: resolved.to,
|
||||
channel,
|
||||
deliver: true,
|
||||
bestEffortDeliver: true,
|
||||
messageChannel: channel,
|
||||
threadId,
|
||||
accountId: origin?.accountId,
|
||||
senderIsOwner: false,
|
||||
},
|
||||
defaultRuntime,
|
||||
params.deps,
|
||||
);
|
||||
} catch (err) {
|
||||
// Agent failed — fall back to a clean restart notice without raw sentinel fields
|
||||
// so the user isn't left completely silent after a restart.
|
||||
enqueueSystemEvent(`${summary}\n${String(err)}`, { sessionKey });
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
consumeRestartSentinel,
|
||||
formatDoctorNonInteractiveHint,
|
||||
formatRestartSentinelInternalContext,
|
||||
formatRestartSentinelMessage,
|
||||
formatRestartSentinelUserMessage,
|
||||
readRestartSentinel,
|
||||
resolveRestartSentinelPath,
|
||||
summarizeRestartSentinel,
|
||||
@ -160,6 +161,139 @@ describe("restart sentinel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRestartSentinelUserMessage", () => {
|
||||
it("returns generic success message regardless of note (note is internal only)", () => {
|
||||
// The `note`/`message` field is an operator annotation — it must never be surfaced
|
||||
// directly to the user. Only the agent (via internalContext) should see it.
|
||||
const payload = {
|
||||
kind: "config-patch" as const,
|
||||
status: "ok" as const,
|
||||
ts: Date.now(),
|
||||
message: "testing restart sentinel",
|
||||
doctorHint: "Run: openclaw doctor --non-interactive",
|
||||
};
|
||||
const result = formatRestartSentinelUserMessage(payload);
|
||||
expect(result).toBe("Gateway restarted successfully.");
|
||||
expect(result).not.toContain("testing restart sentinel");
|
||||
expect(result).not.toContain("config-patch");
|
||||
expect(result).not.toContain("doctor");
|
||||
});
|
||||
|
||||
it("returns generic success message when no note", () => {
|
||||
const payload = {
|
||||
kind: "update" as const,
|
||||
status: "ok" as const,
|
||||
ts: Date.now(),
|
||||
};
|
||||
expect(formatRestartSentinelUserMessage(payload)).toBe("Gateway restarted successfully.");
|
||||
});
|
||||
|
||||
it("returns generic failure message for error status (note is internal only)", () => {
|
||||
// Raw note must not appear in user-facing fallback even on error.
|
||||
const payload = {
|
||||
kind: "config-apply" as const,
|
||||
status: "error" as const,
|
||||
ts: Date.now(),
|
||||
message: "disk full",
|
||||
};
|
||||
const result = formatRestartSentinelUserMessage(payload);
|
||||
expect(result).toBe("Gateway restart failed.");
|
||||
expect(result).not.toContain("disk full");
|
||||
});
|
||||
|
||||
it("returns generic failure message for error without note", () => {
|
||||
const payload = {
|
||||
kind: "restart" as const,
|
||||
status: "error" as const,
|
||||
ts: Date.now(),
|
||||
};
|
||||
expect(formatRestartSentinelUserMessage(payload)).toBe("Gateway restart failed.");
|
||||
});
|
||||
|
||||
it("returns skipped message for skipped status", () => {
|
||||
const payload = {
|
||||
kind: "update" as const,
|
||||
status: "skipped" as const,
|
||||
ts: Date.now(),
|
||||
};
|
||||
expect(formatRestartSentinelUserMessage(payload)).toBe(
|
||||
"Gateway restart skipped (no restart was performed).",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns skipped message for skipped status even with a note", () => {
|
||||
const payload = {
|
||||
kind: "update" as const,
|
||||
status: "skipped" as const,
|
||||
ts: Date.now(),
|
||||
message: "update already up to date",
|
||||
};
|
||||
const result = formatRestartSentinelUserMessage(payload);
|
||||
expect(result).toBe("Gateway restart skipped (no restart was performed).");
|
||||
expect(result).not.toContain("already up to date");
|
||||
});
|
||||
|
||||
it("never includes doctorHint", () => {
|
||||
const payload = {
|
||||
kind: "config-patch" as const,
|
||||
status: "ok" as const,
|
||||
ts: Date.now(),
|
||||
message: "applied config",
|
||||
doctorHint: "Run: openclaw doctor --non-interactive",
|
||||
};
|
||||
expect(formatRestartSentinelUserMessage(payload)).not.toContain("doctor");
|
||||
expect(formatRestartSentinelUserMessage(payload)).not.toContain("openclaw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRestartSentinelInternalContext", () => {
|
||||
it("includes kind, status, note, and doctorHint", () => {
|
||||
const payload = {
|
||||
kind: "config-patch" as const,
|
||||
status: "ok" as const,
|
||||
ts: Date.now(),
|
||||
message: "testing restart sentinel",
|
||||
doctorHint: "Run: openclaw doctor --non-interactive",
|
||||
stats: { mode: "gateway.config-patch", reason: "discovery.mdns.mode changed" },
|
||||
};
|
||||
const result = formatRestartSentinelInternalContext(payload);
|
||||
expect(result).toContain("kind: config-patch");
|
||||
expect(result).toContain("status: ok");
|
||||
expect(result).toContain("note: testing restart sentinel");
|
||||
expect(result).toContain("hint: Run: openclaw doctor");
|
||||
expect(result).toContain("mode: gateway.config-patch");
|
||||
expect(result).toContain("reason: discovery.mdns.mode changed");
|
||||
expect(result).toContain("internal");
|
||||
});
|
||||
|
||||
it("omits empty optional fields", () => {
|
||||
const payload = {
|
||||
kind: "restart" as const,
|
||||
status: "ok" as const,
|
||||
ts: Date.now(),
|
||||
};
|
||||
const result = formatRestartSentinelInternalContext(payload);
|
||||
expect(result).not.toContain("note:");
|
||||
expect(result).not.toContain("hint:");
|
||||
expect(result).not.toContain("reason:");
|
||||
expect(result).not.toContain("mode:");
|
||||
});
|
||||
|
||||
it("omits reason when it duplicates note", () => {
|
||||
const note = "Applying config changes";
|
||||
const payload = {
|
||||
kind: "config-apply" as const,
|
||||
status: "ok" as const,
|
||||
ts: Date.now(),
|
||||
message: note,
|
||||
stats: { reason: note },
|
||||
};
|
||||
const result = formatRestartSentinelInternalContext(payload);
|
||||
const noteOccurrences = result.split(note).length - 1;
|
||||
expect(noteOccurrences).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restart sentinel message dedup", () => {
|
||||
it("omits duplicate Reason: line when stats.reason matches message", () => {
|
||||
const payload = {
|
||||
|
||||
@ -127,6 +127,50 @@ export function formatRestartSentinelMessage(payload: RestartSentinelPayload): s
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean fallback message for system-event delivery when the agent cannot be woken.
|
||||
* Never includes the raw `note`/`message` field — that belongs in the agent's
|
||||
* internal context only (see formatRestartSentinelInternalContext). The note is
|
||||
* an operator annotation, not a user-facing string.
|
||||
*/
|
||||
export function formatRestartSentinelUserMessage(payload: RestartSentinelPayload): string {
|
||||
if (payload.status === "error") {
|
||||
return "Gateway restart failed.";
|
||||
}
|
||||
if (payload.status === "skipped") {
|
||||
return "Gateway restart skipped (no restart was performed).";
|
||||
}
|
||||
return "Gateway restarted successfully.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Full technical restart context injected into the agent's system prompt so
|
||||
* it can reason about and respond to the restart without exposing raw
|
||||
* diagnostic text directly in the user-facing chat message.
|
||||
*/
|
||||
export function formatRestartSentinelInternalContext(payload: RestartSentinelPayload): string {
|
||||
const lines: string[] = [
|
||||
"[Gateway restart context — internal, do not surface raw details to user]",
|
||||
`kind: ${payload.kind}`,
|
||||
`status: ${payload.status}`,
|
||||
];
|
||||
const note = payload.message?.trim();
|
||||
if (note) {
|
||||
lines.push(`note: ${note}`);
|
||||
}
|
||||
const reason = payload.stats?.reason?.trim();
|
||||
if (reason && reason !== note) {
|
||||
lines.push(`reason: ${reason}`);
|
||||
}
|
||||
if (payload.stats?.mode?.trim()) {
|
||||
lines.push(`mode: ${payload.stats.mode.trim()}`);
|
||||
}
|
||||
if (payload.doctorHint?.trim()) {
|
||||
lines.push(`hint: ${payload.doctorHint.trim()}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function summarizeRestartSentinel(payload: RestartSentinelPayload): string {
|
||||
const kind = payload.kind;
|
||||
const status = payload.status;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user