mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
Fix restart sentinel internal continuations (#88161)
* fix restart sentinel internal continuations * update gateway prompt snapshots * stabilize sandbox browser audit timer tests * drive sandbox audit timeouts deterministically * drive gh-read timeout tests deterministically * drive label-open-issues timeout tests deterministically * document deterministic timeout test timers * test: preserve deterministic timer setup after rebase
This commit is contained in:
@@ -646,10 +646,16 @@ describe("runCliAgent spawn path", () => {
|
||||
runId: "run-claude-channel-wrapper",
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "acp",
|
||||
currentChannelId: "telegram:-100123:topic:42",
|
||||
currentThreadTs: "42",
|
||||
currentMessageId: "reply-message-1",
|
||||
});
|
||||
|
||||
expect(params.messageChannel).toBe("telegram");
|
||||
expect(params.messageProvider).toBe("acp");
|
||||
expect(params.currentChannelId).toBe("telegram:-100123:topic:42");
|
||||
expect(params.currentThreadTs).toBe("42");
|
||||
expect(params.currentMessageId).toBe("reply-message-1");
|
||||
expect(params.cwd).toBe("/tmp/task-repo");
|
||||
});
|
||||
|
||||
|
||||
@@ -779,6 +779,9 @@ export function buildRunClaudeCliAgentParams(params: RunClaudeCliAgentParams): R
|
||||
images: params.images,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
currentMessageId: params.currentMessageId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,9 @@ function createTestMcpLoopbackServerConfig(port: number) {
|
||||
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
|
||||
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
|
||||
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
|
||||
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
|
||||
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
|
||||
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
|
||||
"x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}",
|
||||
"x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
|
||||
},
|
||||
@@ -1147,6 +1150,9 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
cfg: expect.any(Object),
|
||||
sessionKey: "agent:main:test",
|
||||
messageProvider: undefined,
|
||||
currentChannelId: undefined,
|
||||
currentThreadTs: undefined,
|
||||
currentMessageId: undefined,
|
||||
accountId: undefined,
|
||||
inboundEventKind: undefined,
|
||||
sourceReplyDeliveryMode: undefined,
|
||||
@@ -1289,11 +1295,17 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
config: createCliBackendConfig(),
|
||||
currentInboundEventKind: "room_event",
|
||||
messageChannel: "telegram",
|
||||
currentChannelId: "telegram:-100123:topic:42",
|
||||
currentThreadTs: "42",
|
||||
currentMessageId: "reply-message-1",
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
|
||||
expect(context.preparedBackend.env).toMatchObject({
|
||||
OPENCLAW_MCP_MESSAGE_CHANNEL: "telegram",
|
||||
OPENCLAW_MCP_CURRENT_CHANNEL_ID: "telegram:-100123:topic:42",
|
||||
OPENCLAW_MCP_CURRENT_THREAD_TS: "42",
|
||||
OPENCLAW_MCP_CURRENT_MESSAGE_ID: "reply-message-1",
|
||||
OPENCLAW_MCP_INBOUND_EVENT_KIND: "room_event",
|
||||
OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: "message_tool_only",
|
||||
});
|
||||
|
||||
@@ -277,6 +277,10 @@ export async function prepareCliRunContext(
|
||||
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
|
||||
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
|
||||
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageChannel ?? params.messageProvider ?? "",
|
||||
OPENCLAW_MCP_CURRENT_CHANNEL_ID: params.currentChannelId ?? "",
|
||||
OPENCLAW_MCP_CURRENT_THREAD_TS: params.currentThreadTs ?? "",
|
||||
OPENCLAW_MCP_CURRENT_MESSAGE_ID:
|
||||
params.currentMessageId != null ? String(params.currentMessageId) : "",
|
||||
OPENCLAW_MCP_INBOUND_EVENT_KIND: params.currentInboundEventKind ?? "",
|
||||
OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: params.sourceReplyDeliveryMode ?? "",
|
||||
}
|
||||
@@ -351,6 +355,9 @@ export async function prepareCliRunContext(
|
||||
cfg: params.config ?? getRuntimeConfig(),
|
||||
sessionKey: params.sessionKey ?? "",
|
||||
messageProvider: params.messageChannel ?? params.messageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
currentMessageId: params.currentMessageId,
|
||||
accountId: params.agentAccountId,
|
||||
inboundEventKind: params.currentInboundEventKind,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
|
||||
@@ -70,6 +70,9 @@ export type RunCliAgentParams = {
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
currentMessageId?: string | number;
|
||||
agentAccountId?: string;
|
||||
/** Trusted sender identity bit for channel action auth. */
|
||||
senderIsOwner?: boolean;
|
||||
|
||||
@@ -137,12 +137,15 @@ describe("gateway tool restart continuation", () => {
|
||||
expect(parameters.properties?.timeoutMs).toMatchObject({ type: "integer", minimum: 1 });
|
||||
});
|
||||
|
||||
it("instructs agents to use continuationMessage when a restart still needs a reply", async () => {
|
||||
it("instructs agents to use continuationMessage for internal post-restart work", async () => {
|
||||
const tool = createGatewayTool();
|
||||
|
||||
expect(tool.description).toContain("still owe the user a reply");
|
||||
expect(tool.description).toContain("post-restart work must continue internally");
|
||||
expect(tool.description).toContain(
|
||||
"visible follow-up from that turn must use the message tool",
|
||||
);
|
||||
expect(tool.description).toContain("continuationMessage");
|
||||
expect(tool.description).toContain("do not write restart sentinel files directly");
|
||||
expect(tool.description).toContain("Do not write restart sentinel files directly");
|
||||
});
|
||||
|
||||
it("writes an agentTurn continuation into the restart sentinel", async () => {
|
||||
@@ -234,9 +237,7 @@ describe("gateway tool restart continuation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults session-scoped restarts to a success continuation", async () => {
|
||||
const { DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE } =
|
||||
await import("../../infra/restart-sentinel.js");
|
||||
it("does not infer a continuation for session-scoped restarts", async () => {
|
||||
const tool = createGatewayTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
config: {},
|
||||
@@ -252,10 +253,7 @@ describe("gateway tool restart continuation", () => {
|
||||
|
||||
const payload = requireRestartSentinelPayload();
|
||||
expect(payload.sessionKey).toBe("agent:main:main");
|
||||
expect(payload.continuation).toEqual({
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
|
||||
});
|
||||
expect(payload.continuation).toBeNull();
|
||||
});
|
||||
|
||||
it("removes the prepared sentinel when restart emission is rejected", async () => {
|
||||
|
||||
@@ -369,7 +369,7 @@ export function createGatewayTool(opts?: {
|
||||
label: "Gateway",
|
||||
name: "gateway",
|
||||
description:
|
||||
"Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If still owe the user a reply, pass one-shot `continuationMessage`; do not write restart sentinel files directly.",
|
||||
"Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If post-restart work must continue internally, pass one-shot `continuationMessage`; visible follow-up from that turn must use the message tool. Do not write restart sentinel files directly.",
|
||||
parameters: GatewayToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
|
||||
@@ -2015,6 +2015,14 @@ export async function runAgentTurnWithFallback(params: {
|
||||
originatingChannel: params.followupRun.originatingChannel,
|
||||
provider: params.sessionCtx.Provider,
|
||||
});
|
||||
const cliCurrentThreadId =
|
||||
params.followupRun.originatingThreadId ?? params.sessionCtx.MessageThreadId;
|
||||
const isRestartSentinelContinuation =
|
||||
params.sessionCtx.InputProvenance?.kind === "internal_system" &&
|
||||
params.sessionCtx.InputProvenance.sourceTool === "restart-sentinel";
|
||||
const cliCurrentMessageId = isRestartSentinelContinuation
|
||||
? params.sessionCtx.ReplyToId
|
||||
: (params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid);
|
||||
const result = await agentTurnTiming.measure("cli_run", () =>
|
||||
runCliAgentWithLifecycle({
|
||||
runId,
|
||||
@@ -2107,6 +2115,13 @@ export async function runAgentTurnWithFallback(params: {
|
||||
skillsSnapshot: params.followupRun.run.skillsSnapshot,
|
||||
messageChannel: params.followupRun.originatingChannel ?? undefined,
|
||||
messageProvider: hookMessageProvider,
|
||||
currentChannelId:
|
||||
params.followupRun.originatingTo ??
|
||||
params.sessionCtx.OriginatingTo ??
|
||||
params.sessionCtx.To,
|
||||
currentThreadTs:
|
||||
cliCurrentThreadId != null ? String(cliCurrentThreadId) : undefined,
|
||||
currentMessageId: cliCurrentMessageId,
|
||||
agentAccountId: params.followupRun.run.agentAccountId,
|
||||
senderIsOwner: params.followupRun.run.senderIsOwner,
|
||||
disableTools: params.opts?.disableTools,
|
||||
|
||||
@@ -309,4 +309,63 @@ describe("agent-runner-utils", () => {
|
||||
expect(context.currentChannelId).toBe("channel:123456789012345678");
|
||||
expect(context.currentMessageId).toBe("msg-9");
|
||||
});
|
||||
|
||||
it("does not expose restart-sentinel synthetic ids as message-tool reply targets", () => {
|
||||
hoisted.getChannelPluginMock.mockReturnValue({
|
||||
threading: {
|
||||
buildToolContext: ({
|
||||
context,
|
||||
}: {
|
||||
context: { To?: string; MessageThreadId?: string | number };
|
||||
}) => ({
|
||||
currentChannelId: context.To,
|
||||
currentThreadTs:
|
||||
context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const context = buildThreadingToolContext({
|
||||
sessionCtx: {
|
||||
Provider: "webchat",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:-1003841603622:topic:928",
|
||||
MessageThreadId: 928,
|
||||
MessageSid: "restart-sentinel:agent:main:telegram:agentTurn:123",
|
||||
InputProvenance: {
|
||||
kind: "internal_system",
|
||||
sourceChannel: "telegram",
|
||||
sourceTool: "restart-sentinel",
|
||||
},
|
||||
},
|
||||
config: {},
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(context.currentChannelId).toBe("telegram:-1003841603622:topic:928");
|
||||
expect(context.currentThreadTs).toBe("928");
|
||||
expect(context.currentMessageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses restart-sentinel reply target when one exists", () => {
|
||||
const context = buildThreadingToolContext({
|
||||
sessionCtx: {
|
||||
Provider: "webchat",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+15550002",
|
||||
ReplyToId: "provider-reply-id",
|
||||
MessageSid: "restart-sentinel:agent:main:whatsapp:agentTurn:123",
|
||||
InputProvenance: {
|
||||
kind: "internal_system",
|
||||
sourceChannel: "whatsapp",
|
||||
sourceTool: "restart-sentinel",
|
||||
},
|
||||
},
|
||||
config: {},
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(context.currentChannelId).toBe("whatsapp:+15550002");
|
||||
expect(context.currentMessageId).toBe("provider-reply-id");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,7 +105,12 @@ export function buildThreadingToolContext(params: {
|
||||
hasRepliedRef: { value: boolean } | undefined;
|
||||
}): ChannelThreadingToolContext {
|
||||
const { sessionCtx, config, hasRepliedRef } = params;
|
||||
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
|
||||
const isRestartSentinelContinuation =
|
||||
sessionCtx.InputProvenance?.kind === "internal_system" &&
|
||||
sessionCtx.InputProvenance.sourceTool === "restart-sentinel";
|
||||
const currentMessageId = isRestartSentinelContinuation
|
||||
? sessionCtx.ReplyToId
|
||||
: (sessionCtx.MessageSidFull ?? sessionCtx.MessageSid);
|
||||
const originProvider = resolveOriginMessageProvider({
|
||||
originatingChannel: sessionCtx.OriginatingChannel,
|
||||
provider: sessionCtx.Provider,
|
||||
|
||||
@@ -128,9 +128,6 @@ describe("handleRestartCommand", () => {
|
||||
});
|
||||
|
||||
it("writes a routed restart sentinel before restarting from chat", async () => {
|
||||
const { DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE } =
|
||||
await import("../../infra/restart-sentinel.js");
|
||||
|
||||
const result = await handleRestartCommand(restartCommandParams(), true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
@@ -147,10 +144,7 @@ describe("handleRestartCommand", () => {
|
||||
});
|
||||
expect(sentinelPayload?.threadId).toBe("thread-1");
|
||||
expect(sentinelPayload?.message).toBe("/restart");
|
||||
expect(sentinelPayload?.continuation).toEqual({
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
|
||||
});
|
||||
expect(sentinelPayload?.continuation).toBeNull();
|
||||
expect(sentinelPayload?.doctorHint).toBe(
|
||||
"Recommended follow-up: run openclaw doctor --non-interactive in a terminal or approvals-capable OpenClaw surface.",
|
||||
);
|
||||
@@ -179,11 +173,7 @@ describe("handleRestartCommand", () => {
|
||||
expect(sentinelPayload?.kind).toBe("restart");
|
||||
expect(sentinelPayload?.status).toBe("ok");
|
||||
expect(sentinelPayload?.sessionKey).toBe("agent:main:telegram:direct:123:thread:thread-1");
|
||||
expect(sentinelPayload?.continuation).toEqual({
|
||||
kind: "agentTurn",
|
||||
message:
|
||||
"The gateway restart completed successfully. Tell the user OpenClaw restarted successfully and continue any pending work.",
|
||||
});
|
||||
expect(sentinelPayload?.continuation).toBeNull();
|
||||
} finally {
|
||||
process.removeListener("SIGUSR1", handler);
|
||||
}
|
||||
|
||||
@@ -880,6 +880,10 @@ describe("createFollowupRunner runtime config", () => {
|
||||
await runner(
|
||||
createQueuedRun({
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: "telegram:-100123:topic:42",
|
||||
originatingThreadId: "42",
|
||||
originatingReplyToId: "reply-42",
|
||||
messageId: "queued-message-1",
|
||||
run: {
|
||||
config: runtimeConfig,
|
||||
sessionId: "session-cli-followup",
|
||||
@@ -887,6 +891,11 @@ describe("createFollowupRunner runtime config", () => {
|
||||
model: "claude-opus-4-7",
|
||||
messageProvider: "telegram",
|
||||
cwd: "/tmp/task-repo",
|
||||
inputProvenance: {
|
||||
kind: "internal_system",
|
||||
sourceChannel: "telegram",
|
||||
sourceTool: "restart-sentinel",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -899,6 +908,9 @@ describe("createFollowupRunner runtime config", () => {
|
||||
expect(call.config).toBe(runtimeConfig);
|
||||
expect(call.cliSessionId).toBe("cli-session-1");
|
||||
expect(call.messageChannel).toBe("telegram");
|
||||
expect(call.currentChannelId).toBe("telegram:-100123:topic:42");
|
||||
expect(call.currentThreadTs).toBe("42");
|
||||
expect(call.currentMessageId).toBe("reply-42");
|
||||
expect(call).toMatchObject({
|
||||
sessionId: "session-cli-followup",
|
||||
sessionKey: "main",
|
||||
|
||||
@@ -728,6 +728,12 @@ export function createFollowupRunner(params: {
|
||||
model,
|
||||
startedAt: cliLifecycleStartedAt,
|
||||
};
|
||||
const isRestartSentinelFollowup =
|
||||
run.inputProvenance?.kind === "internal_system" &&
|
||||
run.inputProvenance.sourceTool === "restart-sentinel";
|
||||
const followupCurrentMessageId = isRestartSentinelFollowup
|
||||
? queued.originatingReplyToId
|
||||
: queued.messageId;
|
||||
const result = await runCliAgentWithLifecycle({
|
||||
runId,
|
||||
provider: cliExecutionProvider,
|
||||
@@ -803,6 +809,12 @@ export function createFollowupRunner(params: {
|
||||
originatingChannel: queued.originatingChannel,
|
||||
provider: run.messageProvider,
|
||||
}),
|
||||
currentChannelId: queued.originatingTo,
|
||||
currentThreadTs:
|
||||
queued.originatingThreadId != null
|
||||
? String(queued.originatingThreadId)
|
||||
: undefined,
|
||||
currentMessageId: followupCurrentMessageId,
|
||||
agentAccountId: run.agentAccountId,
|
||||
disableTools: opts?.disableTools,
|
||||
abortSignal: runAbortSignal,
|
||||
@@ -823,6 +835,12 @@ export function createFollowupRunner(params: {
|
||||
return result;
|
||||
}
|
||||
pendingDeferredCliTerminal = undefined;
|
||||
const isRestartSentinelFollowup =
|
||||
run.inputProvenance?.kind === "internal_system" &&
|
||||
run.inputProvenance.sourceTool === "restart-sentinel";
|
||||
const followupCurrentMessageId = isRestartSentinelFollowup
|
||||
? queued.originatingReplyToId
|
||||
: queued.messageId;
|
||||
const result = await runEmbeddedAgent({
|
||||
allowGatewaySubagentBinding: true,
|
||||
replyOperation,
|
||||
@@ -840,6 +858,7 @@ export function createFollowupRunner(params: {
|
||||
queued.originatingThreadId != null
|
||||
? String(queued.originatingThreadId)
|
||||
: undefined,
|
||||
currentMessageId: followupCurrentMessageId,
|
||||
groupId: run.groupId,
|
||||
groupChannel: run.groupChannel,
|
||||
groupSpace: run.groupSpace,
|
||||
|
||||
@@ -2408,6 +2408,7 @@ describe("runPreparedReply media-only handling", () => {
|
||||
ChatType: "group",
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "channel:24680",
|
||||
ReplyToId: "reply-24680",
|
||||
AccountId: "work",
|
||||
},
|
||||
}),
|
||||
@@ -2415,6 +2416,7 @@ describe("runPreparedReply media-only handling", () => {
|
||||
|
||||
const call = requireRunReplyAgentCall();
|
||||
expect(call?.followupRun.originatingAccountId).toBe("work");
|
||||
expect(call?.followupRun.originatingReplyToId).toBe("reply-24680");
|
||||
});
|
||||
|
||||
it("uses transport thread metadata for followup originatingThreadId", async () => {
|
||||
|
||||
@@ -1216,6 +1216,7 @@ export async function runPreparedReply(
|
||||
originatingTo: ctx.OriginatingTo,
|
||||
originatingAccountId: sessionCtx.AccountId,
|
||||
originatingThreadId,
|
||||
originatingReplyToId: sessionCtx.ReplyToId,
|
||||
originatingChatType: ctx.ChatType,
|
||||
run: {
|
||||
agentId,
|
||||
|
||||
@@ -75,6 +75,8 @@ export type FollowupRun = {
|
||||
originatingAccountId?: string;
|
||||
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
|
||||
originatingThreadId?: string | number;
|
||||
/** Provider reply target for transports that model threads as message replies. */
|
||||
originatingReplyToId?: string;
|
||||
/** Chat type for context-aware threading (e.g., DM vs channel). */
|
||||
originatingChatType?: string;
|
||||
run: {
|
||||
|
||||
@@ -39,6 +39,9 @@ export function createMcpLoopbackServerConfig(port: number) {
|
||||
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
|
||||
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
|
||||
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
|
||||
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
|
||||
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
|
||||
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
|
||||
"x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}",
|
||||
"x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
|
||||
},
|
||||
|
||||
@@ -30,6 +30,9 @@ function logMcpLoopbackHttp(step: string, details: Record<string, unknown>): voi
|
||||
type McpRequestContext = {
|
||||
sessionKey: string;
|
||||
messageProvider: string | undefined;
|
||||
currentChannelId: string | undefined;
|
||||
currentThreadTs: string | undefined;
|
||||
currentMessageId: string | undefined;
|
||||
accountId: string | undefined;
|
||||
inboundEventKind: InboundEventKind | undefined;
|
||||
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined;
|
||||
@@ -188,6 +191,9 @@ export function resolveMcpRequestContext(
|
||||
sessionKey: resolveScopedSessionKey(cfg, getHeader(req, "x-session-key")),
|
||||
messageProvider:
|
||||
normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined,
|
||||
currentChannelId: normalizeOptionalString(getHeader(req, "x-openclaw-current-channel-id")),
|
||||
currentThreadTs: normalizeOptionalString(getHeader(req, "x-openclaw-current-thread-ts")),
|
||||
currentMessageId: normalizeOptionalString(getHeader(req, "x-openclaw-current-message-id")),
|
||||
accountId: normalizeOptionalString(getHeader(req, "x-openclaw-account-id")),
|
||||
inboundEventKind: normalizeMcpInboundEventKind(getHeader(req, "x-openclaw-inbound-event-kind")),
|
||||
sourceReplyDeliveryMode: normalizeMcpSourceReplyDeliveryMode(
|
||||
|
||||
@@ -23,6 +23,9 @@ export function resolveMcpLoopbackScopedTools(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
messageProvider: string | undefined;
|
||||
currentChannelId: string | undefined;
|
||||
currentThreadTs: string | undefined;
|
||||
currentMessageId: string | number | undefined;
|
||||
accountId: string | undefined;
|
||||
inboundEventKind: InboundEventKind | undefined;
|
||||
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined;
|
||||
@@ -32,6 +35,9 @@ export function resolveMcpLoopbackScopedTools(params: {
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.messageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
currentMessageId: params.currentMessageId,
|
||||
accountId: params.accountId,
|
||||
inboundEventKind: params.inboundEventKind,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
@@ -52,6 +58,9 @@ export class McpLoopbackToolCache {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
messageProvider: string | undefined;
|
||||
currentChannelId: string | undefined;
|
||||
currentThreadTs: string | undefined;
|
||||
currentMessageId: string | number | undefined;
|
||||
accountId: string | undefined;
|
||||
inboundEventKind: InboundEventKind | undefined;
|
||||
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined;
|
||||
@@ -60,6 +69,9 @@ export class McpLoopbackToolCache {
|
||||
const cacheKey = [
|
||||
params.sessionKey,
|
||||
params.messageProvider ?? "",
|
||||
params.currentChannelId ?? "",
|
||||
params.currentThreadTs ?? "",
|
||||
params.currentMessageId != null ? String(params.currentMessageId) : "",
|
||||
params.accountId ?? "",
|
||||
params.inboundEventKind ?? "",
|
||||
params.sourceReplyDeliveryMode ?? "",
|
||||
@@ -75,6 +87,9 @@ export class McpLoopbackToolCache {
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.messageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
currentMessageId: params.currentMessageId,
|
||||
accountId: params.accountId,
|
||||
inboundEventKind: params.inboundEventKind,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
|
||||
@@ -21,6 +21,9 @@ type ScopedToolsCall = {
|
||||
sessionKey?: string;
|
||||
accountId?: string;
|
||||
messageProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
currentMessageId?: string | number;
|
||||
inboundEventKind?: string;
|
||||
sourceReplyDeliveryMode?: string;
|
||||
senderIsOwner?: boolean;
|
||||
@@ -170,6 +173,9 @@ describe("mcp loopback server", () => {
|
||||
"x-session-key": "agent:main:telegram:group:chat123",
|
||||
"x-openclaw-account-id": "work",
|
||||
"x-openclaw-message-channel": "telegram",
|
||||
"x-openclaw-current-channel-id": "telegram:chat123",
|
||||
"x-openclaw-current-thread-ts": "42",
|
||||
"x-openclaw-current-message-id": "reply-message-1",
|
||||
"x-openclaw-inbound-event-kind": "room_event",
|
||||
"x-openclaw-source-reply-delivery-mode": "message_tool_only",
|
||||
},
|
||||
@@ -181,6 +187,9 @@ describe("mcp loopback server", () => {
|
||||
expect(call.sessionKey).toBe("agent:main:telegram:group:chat123");
|
||||
expect(call.accountId).toBe("work");
|
||||
expect(call.messageProvider).toBe("telegram");
|
||||
expect(call.currentChannelId).toBe("telegram:chat123");
|
||||
expect(call.currentThreadTs).toBe("42");
|
||||
expect(call.currentMessageId).toBe("reply-message-1");
|
||||
expect(call.inboundEventKind).toBe("room_event");
|
||||
expect(call.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(call.surface).toBe("loopback");
|
||||
@@ -699,6 +708,15 @@ describe("createMcpLoopbackServerConfig", () => {
|
||||
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-message-channel"]).toBe(
|
||||
"${OPENCLAW_MCP_MESSAGE_CHANNEL}",
|
||||
);
|
||||
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-channel-id"]).toBe(
|
||||
"${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
|
||||
);
|
||||
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-thread-ts"]).toBe(
|
||||
"${OPENCLAW_MCP_CURRENT_THREAD_TS}",
|
||||
);
|
||||
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-message-id"]).toBe(
|
||||
"${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
|
||||
);
|
||||
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-source-reply-delivery-mode"]).toBe(
|
||||
"${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
|
||||
);
|
||||
|
||||
@@ -106,6 +106,9 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
|
||||
cfg,
|
||||
sessionKey: requestContext.sessionKey,
|
||||
messageProvider: requestContext.messageProvider,
|
||||
currentChannelId: requestContext.currentChannelId,
|
||||
currentThreadTs: requestContext.currentThreadTs,
|
||||
currentMessageId: requestContext.currentMessageId,
|
||||
accountId: requestContext.accountId,
|
||||
inboundEventKind: requestContext.inboundEventKind,
|
||||
sourceReplyDeliveryMode: requestContext.sourceReplyDeliveryMode,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
|
||||
type RestartSentinelPayload,
|
||||
} from "../../infra/restart-sentinel.js";
|
||||
import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js";
|
||||
import type { RespawnSupervisor } from "../../infra/supervisor-markers.js";
|
||||
import type { UpdateInstallSurface, UpdateRunResult } from "../../infra/update-runner.js";
|
||||
|
||||
@@ -209,10 +206,7 @@ describe("update.run sentinel deliveryContext", () => {
|
||||
to: "webchat:user-123",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(payload.continuation).toEqual({
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
|
||||
});
|
||||
expect(payload.continuation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits deliveryContext when no sessionKey is provided", async () => {
|
||||
@@ -238,10 +232,7 @@ describe("update.run sentinel deliveryContext", () => {
|
||||
accountId: "workspace-1",
|
||||
});
|
||||
expect(payload.threadId).toBe("1234567890.123456");
|
||||
expect(payload.continuation).toEqual({
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
|
||||
});
|
||||
expect(payload.continuation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses an explicit continuationMessage in successful update sentinels", async () => {
|
||||
|
||||
@@ -559,7 +559,7 @@ describe("scheduleRestartSentinelWake", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches agentTurn continuation after the restart notice in the same routed thread", async () => {
|
||||
it("runs agentTurn continuation internally after the restart notice without routed final delivery", async () => {
|
||||
mocks.readRestartSentinel.mockResolvedValue({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
@@ -595,6 +595,7 @@ describe("scheduleRestartSentinelWake", () => {
|
||||
channel: "whatsapp",
|
||||
accountId: "acct-2",
|
||||
routeSessionKey: "agent:main:main",
|
||||
replyOptions: { sourceReplyDeliveryMode: "message_tool_only" },
|
||||
},
|
||||
{
|
||||
Body: "Reply with exactly: Yay! I did it!",
|
||||
@@ -613,9 +614,16 @@ describe("scheduleRestartSentinelWake", () => {
|
||||
Surface: "webchat",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "+15550002",
|
||||
ExplicitDeliverRoute: false,
|
||||
MessageThreadId: "thread-42",
|
||||
},
|
||||
);
|
||||
const deliveredContinuationReply = (
|
||||
mocks.deliverOutboundPayloads.mock.calls as unknown as Array<
|
||||
[{ payloads?: Array<{ text?: string }> }]
|
||||
>
|
||||
).some(([call]) => call.payloads?.some((payload) => payload.text === "done") === true);
|
||||
expect(deliveredContinuationReply).toBe(false);
|
||||
expect(mocks.requestHeartbeat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -886,6 +894,7 @@ describe("scheduleRestartSentinelWake", () => {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
routeSessionKey: "agent:main:telegram:group:-1003826723328:topic:13757",
|
||||
replyOptions: { sourceReplyDeliveryMode: "message_tool_only" },
|
||||
},
|
||||
{
|
||||
Body: "continue in topic",
|
||||
@@ -901,13 +910,13 @@ describe("scheduleRestartSentinelWake", () => {
|
||||
ChatType: "group",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:-1003826723328:topic:13757",
|
||||
ExplicitDeliverRoute: true,
|
||||
ExplicitDeliverRoute: false,
|
||||
MessageThreadId: "13757",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves derived reply transport ids in continuation context", async () => {
|
||||
it("preserves derived reply transport ids in internal continuation context", async () => {
|
||||
mocks.getChannelPlugin.mockReturnValue({
|
||||
id: "whatsapp",
|
||||
meta: {
|
||||
@@ -961,79 +970,12 @@ describe("scheduleRestartSentinelWake", () => {
|
||||
MessageThreadId: undefined,
|
||||
},
|
||||
);
|
||||
expectRecordFields(lastMockCallArg(mocks.deliverOutboundPayloads), {
|
||||
payloads: [
|
||||
{
|
||||
text: "done",
|
||||
replyToId: "reply:thread-42",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips synthetic reply transport ids when no real reply target exists", async () => {
|
||||
mocks.readRestartSentinel.mockResolvedValue({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
deliveryContext: {
|
||||
channel: "whatsapp",
|
||||
to: "+15550002",
|
||||
accountId: "acct-2",
|
||||
},
|
||||
ts: 123,
|
||||
continuation: {
|
||||
kind: "agentTurn",
|
||||
message: "continue",
|
||||
},
|
||||
},
|
||||
} as Awaited<ReturnType<typeof mocks.readRestartSentinel>>);
|
||||
mocks.recordInboundSessionAndDispatchReply.mockImplementationOnce(async (params) => {
|
||||
await params.deliver({
|
||||
text: "done",
|
||||
replyToId: "restart-sentinel:agent:main:main:agentTurn:123",
|
||||
});
|
||||
});
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expectRecordFields(lastMockCallArg(mocks.deliverOutboundPayloads), {
|
||||
payloads: [{ text: "done" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves non-synthetic reply transport ids from continuation payloads", async () => {
|
||||
mocks.readRestartSentinel.mockResolvedValue({
|
||||
payload: {
|
||||
sessionKey: "agent:main:main",
|
||||
deliveryContext: {
|
||||
channel: "whatsapp",
|
||||
to: "+15550002",
|
||||
accountId: "acct-2",
|
||||
},
|
||||
ts: 123,
|
||||
continuation: {
|
||||
kind: "agentTurn",
|
||||
message: "continue",
|
||||
},
|
||||
},
|
||||
} as Awaited<ReturnType<typeof mocks.readRestartSentinel>>);
|
||||
mocks.recordInboundSessionAndDispatchReply.mockImplementationOnce(async (params) => {
|
||||
await params.deliver({
|
||||
text: "done",
|
||||
replyToId: "provider-reply-id",
|
||||
});
|
||||
});
|
||||
|
||||
await scheduleRestartSentinelWake({ deps: {} as never });
|
||||
|
||||
expectRecordFields(lastMockCallArg(mocks.deliverOutboundPayloads), {
|
||||
payloads: [
|
||||
{
|
||||
text: "done",
|
||||
replyToId: "provider-reply-id",
|
||||
},
|
||||
],
|
||||
});
|
||||
const deliveredContinuationReply = (
|
||||
mocks.deliverOutboundPayloads.mock.calls as unknown as Array<
|
||||
[{ payloads?: Array<{ text?: string }> }]
|
||||
>
|
||||
).some(([call]) => call.payloads?.some((payload) => payload.text === "done") === true);
|
||||
expect(deliveredContinuationReply).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches agentTurn continuation from session delivery context when sentinel routing is empty", async () => {
|
||||
@@ -1260,8 +1202,14 @@ describe("scheduleRestartSentinelWake", () => {
|
||||
>
|
||||
).some(([call]) => call.payloads?.some((payload) => payload.text === busyReply) === true);
|
||||
expect(deliveredBusyReply).toBe(false);
|
||||
const deliveredFinalReply = (
|
||||
mocks.deliverOutboundPayloads.mock.calls as unknown as Array<
|
||||
[{ payloads?: Array<{ text?: string }> }]
|
||||
>
|
||||
).some(([call]) => call.payloads?.some((payload) => payload.text === "done") === true);
|
||||
expect(deliveredFinalReply).toBe(false);
|
||||
expectRecordFields(lastMockCallArg(mocks.deliverOutboundPayloads), {
|
||||
payloads: [{ text: "done" }],
|
||||
payloads: [{ text: "restart message" }],
|
||||
});
|
||||
expect(mocks.logWarn.mock.calls).toEqual(
|
||||
Array.from({ length: 6 }, () => [
|
||||
|
||||
@@ -201,19 +201,6 @@ function resolveRestartContinuationRoute(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRestartContinuationOutboundPayload(params: {
|
||||
payload: OutboundReplyPayload;
|
||||
messageId: string;
|
||||
replyToId?: string;
|
||||
}): OutboundReplyPayload {
|
||||
if (params.payload.replyToId !== params.messageId) {
|
||||
return params.payload;
|
||||
}
|
||||
const payload: OutboundReplyPayload = { ...params.payload };
|
||||
delete payload.replyToId;
|
||||
return params.replyToId ? { ...payload, replyToId: params.replyToId } : payload;
|
||||
}
|
||||
|
||||
function isRestartContinuationBusyPayload(payload: OutboundReplyPayload): boolean {
|
||||
return (
|
||||
typeof payload.text === "string" && payload.text.trim() === REPLY_RUN_STILL_SHUTTING_DOWN_TEXT
|
||||
@@ -313,7 +300,7 @@ async function deliverQueuedSessionDelivery(params: {
|
||||
ReplyToId: route.replyToId,
|
||||
OriginatingChannel: route.channel,
|
||||
OriginatingTo: route.to,
|
||||
ExplicitDeliverRoute: true,
|
||||
ExplicitDeliverRoute: false,
|
||||
MessageThreadId: route.threadId,
|
||||
},
|
||||
{
|
||||
@@ -331,50 +318,20 @@ async function deliverQueuedSessionDelivery(params: {
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
replyOptions: {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
},
|
||||
delivery: {
|
||||
preparePayload: (payload) => {
|
||||
if (isRestartContinuationBusyPayload(payload)) {
|
||||
throw new Error(RESTART_CONTINUATION_BUSY_RETRY_ERROR);
|
||||
}
|
||||
return resolveRestartContinuationOutboundPayload({
|
||||
payload,
|
||||
messageId,
|
||||
replyToId: route.replyToId,
|
||||
});
|
||||
},
|
||||
durable: (_payload, info) =>
|
||||
info.kind === "final"
|
||||
? {
|
||||
to: route.to,
|
||||
replyToId: route.replyToId,
|
||||
threadId: route.threadId,
|
||||
deps: params.deps,
|
||||
}
|
||||
: false,
|
||||
deliver: async (payload) => {
|
||||
const send = await sendDurableMessageBatch({
|
||||
cfg,
|
||||
channel: route.channel,
|
||||
to: route.to,
|
||||
accountId: route.accountId,
|
||||
replyToId: route.replyToId,
|
||||
threadId: route.threadId,
|
||||
payloads: [payload],
|
||||
session: buildOutboundSessionContext({
|
||||
cfg,
|
||||
sessionKey: canonicalKey,
|
||||
}),
|
||||
deps: params.deps,
|
||||
bestEffort: false,
|
||||
});
|
||||
if (send.status === "failed" || send.status === "partial_failed") {
|
||||
throw send.error;
|
||||
}
|
||||
const results = send.status === "sent" ? send.results : [];
|
||||
if (results.length === 0) {
|
||||
throw new Error("restart continuation delivery returned no results");
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
durable: false,
|
||||
// Restart continuations are internal lifecycle turns. Visible follow-up
|
||||
// must go through the message tool; automatic final delivery stays off.
|
||||
deliver: async () => ({ visibleReplySent: false }),
|
||||
onError: (err, info) => {
|
||||
dispatchError ??= err;
|
||||
log.warn(`restart continuation dispatch failed during ${info.kind}: ${String(err)}`, {
|
||||
|
||||
@@ -36,6 +36,9 @@ export function resolveGatewayScopedTools(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
messageProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
currentMessageId?: string | number;
|
||||
accountId?: string;
|
||||
inboundEventKind?: InboundEventKind;
|
||||
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
|
||||
@@ -150,6 +153,9 @@ export function resolveGatewayScopedTools(params: {
|
||||
sourceReplyDeliveryMode,
|
||||
agentTo: params.agentTo,
|
||||
agentThreadId: params.agentThreadId,
|
||||
currentChannelId: params.currentChannelId ?? params.agentTo,
|
||||
currentThreadTs: params.currentThreadTs ?? params.agentThreadId,
|
||||
currentMessageId: params.currentMessageId,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
|
||||
allowMediaInvokeCommands: params.allowMediaInvokeCommands,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
|
||||
buildRestartSuccessContinuation,
|
||||
consumeRestartSentinel,
|
||||
finalizeUpdateRestartSentinelRunningVersion,
|
||||
@@ -271,11 +270,8 @@ describe("restart sentinel", () => {
|
||||
});
|
||||
|
||||
describe("restart success continuation", () => {
|
||||
it("builds the default agent turn for session-scoped restarts", () => {
|
||||
expect(buildRestartSuccessContinuation({ sessionKey: "agent:main:main" })).toEqual({
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
|
||||
});
|
||||
it("does not infer an agent turn from session context alone", () => {
|
||||
expect(buildRestartSuccessContinuation({ sessionKey: "agent:main:main" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps explicit continuation messages", () => {
|
||||
|
||||
@@ -65,9 +65,6 @@ export type RestartSentinel = {
|
||||
payload: RestartSentinelPayload;
|
||||
};
|
||||
|
||||
export const DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE =
|
||||
"The gateway restart completed successfully. Tell the user OpenClaw restarted successfully and continue any pending work.";
|
||||
|
||||
const SENTINEL_FILENAME = "restart-sentinel.json";
|
||||
|
||||
export function formatDoctorNonInteractiveHint(
|
||||
@@ -170,9 +167,7 @@ export function buildRestartSuccessContinuation(params: {
|
||||
if (message) {
|
||||
return { kind: "agentTurn", message };
|
||||
}
|
||||
return params.sessionKey?.trim()
|
||||
? { kind: "agentTurn", message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE }
|
||||
: null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readRestartSentinel(
|
||||
|
||||
@@ -34,6 +34,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("security audit sandbox browser findings", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("warns when sandbox browser containers have missing or stale hash labels", async () => {
|
||||
const findings = await collectSandboxBrowserHashLabelFindings({
|
||||
execDockerRawFn: async (args: string[]) => {
|
||||
@@ -85,14 +93,18 @@ describe("security audit sandbox browser findings", () => {
|
||||
});
|
||||
|
||||
it("bounds sandbox browser Docker probes that do not return", async () => {
|
||||
vi.useFakeTimers();
|
||||
let probeSignal: AbortSignal | undefined;
|
||||
const startedAt = Date.now();
|
||||
let markProbeStarted!: () => void;
|
||||
const probeStarted = new Promise<void>((resolve) => {
|
||||
markProbeStarted = resolve;
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
const findingsPromise = collectSandboxBrowserHashLabelFindings({
|
||||
timeoutMs: 1,
|
||||
timeoutMs: 250,
|
||||
execDockerRawFn: async (_args, opts) => {
|
||||
probeSignal = opts?.signal;
|
||||
markProbeStarted();
|
||||
return await new Promise((_, reject) =>
|
||||
opts?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
||||
once: true,
|
||||
@@ -100,10 +112,11 @@ describe("security audit sandbox browser findings", () => {
|
||||
);
|
||||
},
|
||||
});
|
||||
await probeStarted;
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
const findings = await findingsPromise;
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(1000);
|
||||
expect(probeSignal?.aborted).toBe(true);
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -114,11 +127,15 @@ describe("security audit sandbox browser findings", () => {
|
||||
});
|
||||
|
||||
it("stops probing remaining sandbox browser containers after a Docker timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: string[] = [];
|
||||
let markHungProbeStarted!: () => void;
|
||||
const hungProbeStarted = new Promise<void>((resolve) => {
|
||||
markHungProbeStarted = resolve;
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
const findingsPromise = collectSandboxBrowserHashLabelFindings({
|
||||
timeoutMs: 1,
|
||||
timeoutMs: 250,
|
||||
execDockerRawFn: async (args, opts) => {
|
||||
calls.push(`${args[0] ?? ""}:${args.at(-1) ?? ""}`);
|
||||
if (args[0] === "ps") {
|
||||
@@ -128,6 +145,7 @@ describe("security audit sandbox browser findings", () => {
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
markHungProbeStarted();
|
||||
return await new Promise((_, reject) =>
|
||||
opts?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
||||
once: true,
|
||||
@@ -135,7 +153,9 @@ describe("security audit sandbox browser findings", () => {
|
||||
);
|
||||
},
|
||||
});
|
||||
await hungProbeStarted;
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
const findings = await findingsPromise;
|
||||
|
||||
expect(calls).toEqual(["ps:{{.Names}}", "inspect:openclaw-sbx-browser-hung"]);
|
||||
|
||||
@@ -811,7 +811,7 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If still owe the user a reply, pass one-shot `continuationMessage`; do not write restart sentinel files directly.",
|
||||
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If post-restart work must continue internally, pass one-shot `continuationMessage`; visible follow-up from that turn must use the message tool. Do not write restart sentinel files directly.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"action": {
|
||||
|
||||
@@ -847,7 +847,7 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If still owe the user a reply, pass one-shot `continuationMessage`; do not write restart sentinel files directly.",
|
||||
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If post-restart work must continue internally, pass one-shot `continuationMessage`; visible follow-up from that turn must use the message tool. Do not write restart sentinel files directly.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"action": {
|
||||
|
||||
@@ -811,7 +811,7 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If still owe the user a reply, pass one-shot `continuationMessage`; do not write restart sentinel files directly.",
|
||||
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If post-restart work must continue internally, pass one-shot `continuationMessage`; visible follow-up from that turn must use the message tool. Do not write restart sentinel files directly.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"action": {
|
||||
|
||||
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 41146,
|
||||
"roughTokens": 10287
|
||||
"chars": 41222,
|
||||
"roughTokens": 10306
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 2988,
|
||||
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6925
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 68848,
|
||||
"roughTokens": 17212
|
||||
"chars": 68924,
|
||||
"roughTokens": 17231
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1629,
|
||||
|
||||
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 40867,
|
||||
"roughTokens": 10217
|
||||
"chars": 40943,
|
||||
"roughTokens": 10236
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 1964,
|
||||
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6544
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 67045,
|
||||
"roughTokens": 16762
|
||||
"chars": 67121,
|
||||
"roughTokens": 16781
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1129,
|
||||
|
||||
@@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 41962,
|
||||
"roughTokens": 10491
|
||||
"chars": 42038,
|
||||
"roughTokens": 10510
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 1983,
|
||||
@@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6780
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 69083,
|
||||
"roughTokens": 17271
|
||||
"chars": 69159,
|
||||
"roughTokens": 17290
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1367,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildReadPermissions,
|
||||
githubJson,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "../../scripts/gh-read.js";
|
||||
|
||||
describe("gh-read helpers", () => {
|
||||
beforeEach(() => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -59,19 +59,27 @@ describe("gh-read helpers", () => {
|
||||
});
|
||||
|
||||
it("aborts stalled GitHub API fetches at the request timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
let signal: AbortSignal | undefined;
|
||||
let markFetchStarted!: () => void;
|
||||
const fetchStarted = new Promise<void>((resolve) => {
|
||||
markFetchStarted = resolve;
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
const request = githubJson("/app", "token", undefined, {
|
||||
timeoutMs: 5,
|
||||
fetchImpl: ((_url, init) => {
|
||||
signal = init?.signal ?? undefined;
|
||||
markFetchStarted();
|
||||
return new Promise(() => {});
|
||||
}) as typeof fetch,
|
||||
});
|
||||
const rejection = expect(request).rejects.toThrow(/GitHub API GET \/app exceeded timeout/u);
|
||||
|
||||
const rejected = expect(request).rejects.toThrow(/GitHub API GET \/app exceeded timeout/u);
|
||||
await fetchStarted;
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await rejected;
|
||||
|
||||
await rejection;
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
@@ -82,12 +90,13 @@ describe("gh-read helpers", () => {
|
||||
timeoutMs: 5,
|
||||
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
|
||||
});
|
||||
|
||||
const rejected = expect(request).rejects.toThrow(
|
||||
const rejection = expect(request).rejects.toThrow(
|
||||
/GitHub API GET \/app\/installations exceeded timeout/u,
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await rejected;
|
||||
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("bounds GitHub API error response bodies", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { testing } from "../../scripts/label-open-issues.ts";
|
||||
|
||||
const labelItem = {
|
||||
@@ -9,6 +9,12 @@ const labelItem = {
|
||||
};
|
||||
|
||||
describe("label-open-issues helpers", () => {
|
||||
// Timeout tests below advance fake timers explicitly so CI shard load cannot
|
||||
// turn a bounded request-timeout assertion into a wall-clock wait.
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("classifies items from OpenAI structured response text", async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
@@ -37,34 +43,49 @@ describe("label-open-issues helpers", () => {
|
||||
|
||||
it("aborts stalled OpenAI classification fetches at the request timeout", async () => {
|
||||
let signal: AbortSignal | undefined;
|
||||
let markFetchStarted!: () => void;
|
||||
const fetchStarted = new Promise<void>((resolve) => {
|
||||
markFetchStarted = resolve;
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
const request = testing.classifyItem(labelItem, "issue", {
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
timeoutMs: 5,
|
||||
fetchImpl: ((_url, init) => {
|
||||
signal = init?.signal ?? undefined;
|
||||
markFetchStarted();
|
||||
return new Promise(() => {});
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(request).rejects.toThrow(
|
||||
const rejection = expect(request).rejects.toThrow(
|
||||
/OpenAI issue label classification request exceeded timeout/u,
|
||||
);
|
||||
|
||||
await fetchStarted;
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
|
||||
await rejection;
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("times out stalled OpenAI classification body reads", async () => {
|
||||
const response = new Response(new ReadableStream({}), { status: 200 });
|
||||
vi.useFakeTimers();
|
||||
const request = testing.classifyItem(labelItem, "issue", {
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
timeoutMs: 5,
|
||||
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(request).rejects.toThrow(
|
||||
const rejection = expect(request).rejects.toThrow(
|
||||
/OpenAI issue label classification request exceeded timeout/u,
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("bounds OpenAI error response bodies", async () => {
|
||||
|
||||
Reference in New Issue
Block a user