mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 17:31:31 +08:00
Compare commits
4 Commits
fix/agent-
...
codex/red-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59d5ee8fce | ||
|
|
b48d2438d3 | ||
|
|
369288718e | ||
|
|
bcd926cc4f |
@@ -192,109 +192,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the OpenClaw session key to the managed OpenClaw tools MCP bridge", () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const readScopedMcpEnv = (sessionKey: string) => {
|
||||
const delegate = (
|
||||
runtime as unknown as {
|
||||
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
|
||||
}
|
||||
).resolveOpenClawToolsDelegateForSession(sessionKey) as {
|
||||
options: {
|
||||
mcpServers?: Array<{
|
||||
env?: Array<{ name: string; value: string }>;
|
||||
name: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
return delegate.options.mcpServers?.find((server) => server.name === "openclaw-tools")?.env;
|
||||
};
|
||||
|
||||
expect(readScopedMcpEnv("agent:worker:main")).toContainEqual({
|
||||
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
|
||||
value: "agent:worker:main",
|
||||
});
|
||||
expect(readScopedMcpEnv("agent:research:main")).toContainEqual({
|
||||
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
|
||||
value: "agent:research:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps managed OpenClaw tools MCP delegates reachable for fresh sessions", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const exposedRuntime = runtime as unknown as {
|
||||
openclawToolsSessionDelegates: Map<string, unknown>;
|
||||
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
|
||||
};
|
||||
|
||||
const firstDelegate =
|
||||
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main");
|
||||
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
|
||||
|
||||
await runtime.prepareFreshSession({ sessionKey: "agent:worker:main" });
|
||||
|
||||
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
|
||||
expect(exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main")).toBe(
|
||||
firstDelegate,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the no-MCP delegate for startup probes when the OpenClaw tools bridge is enabled", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const defaultProbe = vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined);
|
||||
const safeProbe = vi
|
||||
.spyOn(bridgeSafeDelegate, "probeAvailability")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await runtime.probeAvailability();
|
||||
|
||||
expect(safeProbe).toHaveBeenCalledTimes(1);
|
||||
expect(defaultProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes OpenClaw Codex model ids for ACP startup", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
@@ -1266,46 +1163,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
expect(baseStore["load"]).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases managed OpenClaw tools MCP delegates after close", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const { runtime } = makeRuntime(baseStore, {
|
||||
openclawToolsMcpBridgeEnabled: true,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "openclaw-tools",
|
||||
command: "node",
|
||||
args: ["dist/mcp/openclaw-tools-serve.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const exposedRuntime = runtime as unknown as {
|
||||
openclawToolsSessionDelegates: Map<string, { close: AcpRuntime["close"] }>;
|
||||
resolveOpenClawToolsDelegateForSession(sessionKey: string): {
|
||||
close: AcpRuntime["close"];
|
||||
};
|
||||
};
|
||||
const scopedDelegate =
|
||||
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:codex:main");
|
||||
const close = vi.spyOn(scopedDelegate, "close").mockResolvedValue(undefined);
|
||||
|
||||
await runtime.close({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:main",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:main",
|
||||
},
|
||||
reason: "closed",
|
||||
});
|
||||
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:codex:main")).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up OpenClaw-owned ACPX process trees after close", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => ({
|
||||
|
||||
@@ -50,7 +50,6 @@ type OpenClawAcpxRuntimeOptions = AcpRuntimeOptions & {
|
||||
openclawWrapperRoot?: string;
|
||||
openclawGatewayInstanceId?: string;
|
||||
openclawProcessLeaseStore?: AcpxProcessLeaseStore;
|
||||
openclawToolsMcpBridgeEnabled?: boolean;
|
||||
};
|
||||
type AcpxRuntimeTestOptions = Record<string, unknown> & {
|
||||
openclawProcessCleanup?: AcpxProcessCleanupDeps;
|
||||
@@ -58,10 +57,6 @@ type AcpxRuntimeTestOptions = Record<string, unknown> & {
|
||||
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
|
||||
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
|
||||
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
|
||||
type AcpxMcpServer = NonNullable<AcpRuntimeOptions["mcpServers"]>[number];
|
||||
|
||||
const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
|
||||
const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY";
|
||||
|
||||
type ResetAwareSessionStore = AcpSessionStore & {
|
||||
markFresh: (sessionKey: string) => void;
|
||||
@@ -687,33 +682,6 @@ function shouldUseDistinctBridgeDelegate(options: AcpRuntimeOptions): boolean {
|
||||
return Array.isArray(mcpServers) && mcpServers.length > 0;
|
||||
}
|
||||
|
||||
function withOpenClawToolsMcpSessionEnv(params: {
|
||||
enabled: boolean | undefined;
|
||||
mcpServers: AcpRuntimeOptions["mcpServers"];
|
||||
sessionKey: string;
|
||||
}): AcpRuntimeOptions["mcpServers"] {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!params.enabled || !sessionKey || !params.mcpServers?.length) {
|
||||
return params.mcpServers;
|
||||
}
|
||||
let changed = false;
|
||||
const nextServers = params.mcpServers.map((server): AcpxMcpServer => {
|
||||
if (server.name !== ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME || !("command" in server)) {
|
||||
return server;
|
||||
}
|
||||
changed = true;
|
||||
const env = [
|
||||
...server.env.filter((entry) => entry.name !== OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV),
|
||||
{
|
||||
name: OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
|
||||
value: sessionKey,
|
||||
},
|
||||
];
|
||||
return { ...server, env };
|
||||
});
|
||||
return changed ? nextServers : params.mcpServers;
|
||||
}
|
||||
|
||||
/** OpenClaw-managed ACP runtime implementation backed by the upstream acpx runtime. */
|
||||
export class AcpxRuntime implements AcpRuntime {
|
||||
private readonly sessionStore: ResetAwareSessionStore;
|
||||
@@ -725,10 +693,6 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
private readonly delegate: BaseAcpxRuntime;
|
||||
private readonly bridgeSafeDelegate: BaseAcpxRuntime;
|
||||
private readonly probeDelegate: BaseAcpxRuntime;
|
||||
private readonly delegateOptions: AcpRuntimeOptions;
|
||||
private readonly delegateTestOptions: BaseAcpxRuntimeTestOptions;
|
||||
private readonly openclawToolsMcpBridgeEnabled: boolean;
|
||||
private readonly openclawToolsSessionDelegates = new Map<string, BaseAcpxRuntime>();
|
||||
private readonly processCleanupDeps: AcpxProcessCleanupDeps | undefined;
|
||||
private readonly wrapperRoot: string | undefined;
|
||||
private readonly gatewayInstanceId: string | undefined;
|
||||
@@ -742,7 +706,6 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
this.wrapperRoot = options.openclawWrapperRoot;
|
||||
this.gatewayInstanceId = options.openclawGatewayInstanceId;
|
||||
this.processLeaseStore = options.openclawProcessLeaseStore;
|
||||
this.openclawToolsMcpBridgeEnabled = options.openclawToolsMcpBridgeEnabled === true;
|
||||
this.cwd = options.cwd;
|
||||
this.sessionStore = createResetAwareSessionStore(options.sessionStore, {
|
||||
gatewayInstanceId: this.gatewayInstanceId,
|
||||
@@ -760,21 +723,20 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
sessionStore: this.sessionStore,
|
||||
agentRegistry: this.scopedAgentRegistry,
|
||||
};
|
||||
this.delegateOptions = sharedOptions;
|
||||
this.delegateTestOptions = delegateTestOptions as BaseAcpxRuntimeTestOptions;
|
||||
this.delegate = new BaseAcpxRuntime(sharedOptions, this.delegateTestOptions);
|
||||
this.delegate = new BaseAcpxRuntime(
|
||||
sharedOptions,
|
||||
delegateTestOptions as BaseAcpxRuntimeTestOptions,
|
||||
);
|
||||
this.bridgeSafeDelegate = shouldUseDistinctBridgeDelegate(options)
|
||||
? new BaseAcpxRuntime(
|
||||
{
|
||||
...sharedOptions,
|
||||
mcpServers: [],
|
||||
},
|
||||
this.delegateTestOptions,
|
||||
delegateTestOptions as BaseAcpxRuntimeTestOptions,
|
||||
)
|
||||
: this.delegate;
|
||||
this.probeDelegate = this.openclawToolsMcpBridgeEnabled
|
||||
? this.bridgeSafeDelegate
|
||||
: this.resolveDelegateForAgent(resolveProbeAgentName(options));
|
||||
this.probeDelegate = this.resolveDelegateForAgent(resolveProbeAgentName(options));
|
||||
}
|
||||
|
||||
private resolveDelegateForAgent(agentName: string | undefined): BaseAcpxRuntime {
|
||||
@@ -789,57 +751,6 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
return shouldUseBridgeSafeDelegateForCommand(command) ? this.bridgeSafeDelegate : this.delegate;
|
||||
}
|
||||
|
||||
private resolveDelegateForSession(params: {
|
||||
command: string | undefined;
|
||||
sessionKey: string;
|
||||
}): BaseAcpxRuntime {
|
||||
if (shouldUseBridgeSafeDelegateForCommand(params.command)) {
|
||||
return this.bridgeSafeDelegate;
|
||||
}
|
||||
return this.resolveOpenClawToolsDelegateForSession(params.sessionKey);
|
||||
}
|
||||
|
||||
private resolveOpenClawToolsDelegateForSession(sessionKey: string): BaseAcpxRuntime {
|
||||
if (!this.openclawToolsMcpBridgeEnabled) {
|
||||
return this.delegate;
|
||||
}
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedSessionKey) {
|
||||
return this.delegate;
|
||||
}
|
||||
const cached = this.openclawToolsSessionDelegates.get(normalizedSessionKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Upstream acpx captures mcpServers at runtime construction. The managed
|
||||
// OpenClaw tools bridge needs per-session identity, so cache one delegate
|
||||
// per session with the scoped MCP env already embedded.
|
||||
const delegate = new BaseAcpxRuntime(
|
||||
{
|
||||
...this.delegateOptions,
|
||||
mcpServers: withOpenClawToolsMcpSessionEnv({
|
||||
enabled: this.openclawToolsMcpBridgeEnabled,
|
||||
mcpServers: this.delegateOptions.mcpServers,
|
||||
sessionKey: normalizedSessionKey,
|
||||
}),
|
||||
},
|
||||
this.delegateTestOptions,
|
||||
);
|
||||
this.openclawToolsSessionDelegates.set(normalizedSessionKey, delegate);
|
||||
return delegate;
|
||||
}
|
||||
|
||||
private releaseOpenClawToolsDelegateForSession(sessionKey: string): void {
|
||||
if (!this.openclawToolsMcpBridgeEnabled) {
|
||||
return;
|
||||
}
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedSessionKey) {
|
||||
return;
|
||||
}
|
||||
this.openclawToolsSessionDelegates.delete(normalizedSessionKey);
|
||||
}
|
||||
|
||||
private async resolveDelegateForHandle(handle: AcpRuntimeHandle): Promise<BaseAcpxRuntime> {
|
||||
const record = await this.sessionStore.load(handle.acpxRecordId ?? handle.sessionKey);
|
||||
return this.resolveDelegateForLoadedRecord(handle, record);
|
||||
@@ -851,17 +762,9 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
): BaseAcpxRuntime {
|
||||
const recordCommand = readAgentCommandFromRecord(record);
|
||||
if (recordCommand) {
|
||||
return this.resolveDelegateForSession({
|
||||
command: recordCommand,
|
||||
sessionKey: handle.sessionKey,
|
||||
});
|
||||
return this.resolveDelegateForCommand(recordCommand);
|
||||
}
|
||||
const agentName = readAgentFromHandle(handle);
|
||||
const command = resolveAgentCommandForName({
|
||||
agentName,
|
||||
agentRegistry: this.agentRegistry,
|
||||
});
|
||||
return this.resolveDelegateForSession({ command, sessionKey: handle.sessionKey });
|
||||
return this.resolveDelegateForAgent(readAgentFromHandle(handle));
|
||||
}
|
||||
|
||||
private async resolveCommandForHandle(handle: AcpRuntimeHandle): Promise<string | undefined> {
|
||||
@@ -1077,7 +980,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
agentName: input.agent,
|
||||
agentRegistry: this.agentRegistry,
|
||||
});
|
||||
const delegate = this.resolveDelegateForSession({ command, sessionKey: input.sessionKey });
|
||||
const delegate = this.resolveDelegateForCommand(command);
|
||||
const claudeModelOverride = isClaudeAcpCommand(command)
|
||||
? normalizeClaudeAcpModelOverride(input.model)
|
||||
: undefined;
|
||||
@@ -1361,9 +1264,6 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
}
|
||||
|
||||
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
|
||||
// Fresh reset has no ACP handle to close the delegate's upstream client.
|
||||
// Keep the scoped delegate reachable so the next ensure can replace it;
|
||||
// close() owns cache release when the session lifecycle ends.
|
||||
this.sessionStore.markFresh(input.sessionKey);
|
||||
}
|
||||
|
||||
@@ -1372,9 +1272,8 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
);
|
||||
let closeSucceeded;
|
||||
const delegate = this.resolveDelegateForLoadedRecord(input.handle, record);
|
||||
try {
|
||||
await delegate.close({
|
||||
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
|
||||
handle: input.handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
@@ -1383,9 +1282,6 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
} finally {
|
||||
await this.cleanupProcessTreeForRecord(input.handle, record);
|
||||
}
|
||||
if (closeSucceeded) {
|
||||
this.releaseOpenClawToolsDelegateForSession(input.handle.sessionKey);
|
||||
}
|
||||
if (closeSucceeded && input.discardPersistentState) {
|
||||
this.sessionStore.markFresh(input.handle.sessionKey);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime
|
||||
}),
|
||||
probeAgent: params.pluginConfig.probeAgent,
|
||||
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
|
||||
openclawToolsMcpBridgeEnabled: params.pluginConfig.openClawToolsMcpBridge,
|
||||
permissionMode: params.pluginConfig.permissionMode,
|
||||
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
|
||||
timeoutMs: resolveAcpxTimerTimeoutMs(params.pluginConfig.timeoutSeconds),
|
||||
|
||||
@@ -197,7 +197,6 @@ export const signalApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda
|
||||
conversationKey: entry.conversationKey,
|
||||
messageId: entry.messageId,
|
||||
approvalId: request.id,
|
||||
approvalKind: view.approvalKind,
|
||||
allowedDecisions: pendingPayload.reactionPayload.allowedDecisions,
|
||||
targetAuthorKeys: entry.targetAuthorKeys,
|
||||
route: {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
buildPluginApprovalPendingReplyPayload,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
// Signal tests cover approval reactions plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSignalApprovalReactionHintToText,
|
||||
addSignalApprovalReactionHintToStructuredPayload,
|
||||
appendSignalApprovalReactionHintForOutboundMessage,
|
||||
buildSignalApprovalReactionHint,
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
maybeResolveSignalApprovalReaction,
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload,
|
||||
registerSignalApprovalReactionTargetForOutboundMessage,
|
||||
registerSignalApprovalReactionTarget,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
@@ -82,220 +78,7 @@ describe("Signal approval reactions", () => {
|
||||
).toBe(prompt);
|
||||
});
|
||||
|
||||
it("registers delivered structured approval payloads for reactions", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-structured-approval",
|
||||
approvalSlug: "exec-str",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000012",
|
||||
toJid: "+15551230000",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000012",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-structured-approval",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not register metadata-only approval payloads without visible reaction hints", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-hidden-reaction",
|
||||
approvalSlug: "exec-hid",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf hidden",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000015",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000015",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("registers only delivered chunks that contain visible reaction hints", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-chunked-reaction",
|
||||
approvalSlug: "exec-ch",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf chunked",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000016",
|
||||
meta: {
|
||||
signalVisibleText: "Exec approval required\n\nReact with:\n\n👍 Allow Once\n👎 Deny",
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000017",
|
||||
meta: {
|
||||
signalVisibleText: "Continuation chunk without controls",
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000016",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "exec-chunked-reaction",
|
||||
decision: "allow-once",
|
||||
});
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000017",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("registers delivered structured plugin approval payloads using metadata kind", async () => {
|
||||
it("registers target-mode outbound approval prompts for reactions", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
@@ -310,106 +93,70 @@ describe("Signal approval reactions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildPluginApprovalPendingReplyPayload({
|
||||
request: {
|
||||
id: "plugin-structured-approval",
|
||||
request: {
|
||||
title: "Sensitive plugin action",
|
||||
description: "Needs approval",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
},
|
||||
createdAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
},
|
||||
nowMs: 1_000,
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
const text =
|
||||
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny";
|
||||
const textWithHint = appendSignalApprovalReactionHintForOutboundMessage({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
text,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(textWithHint).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
registerSignalApprovalReactionTargetForOutboundMessage({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000013",
|
||||
},
|
||||
],
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
messageId: "1700000000009",
|
||||
text: textWithHint,
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000013",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "plugin-structured-approval",
|
||||
approvalKind: "plugin",
|
||||
const handled = await maybeResolveSignalApprovalReaction({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000009",
|
||||
reactionKey: "👍",
|
||||
actorId: "+15551230000",
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(resolverMocks.resolveSignalApproval).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
approvalId: "plugin:abc",
|
||||
decision: "allow-once",
|
||||
senderId: "+15551230000",
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not register delivered structured approval payloads without explicit approvers", () => {
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-no-approvers",
|
||||
approvalSlug: "exec-no",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
});
|
||||
const deliveredPayload = {
|
||||
...payload,
|
||||
text: addSignalApprovalReactionHintToText({
|
||||
text: payload.text ?? "",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
}),
|
||||
};
|
||||
it("keeps target-mode outbound prompts manual when the target route is disabled", () => {
|
||||
const text =
|
||||
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny";
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
appendSignalApprovalReactionHintForOutboundMessage({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {},
|
||||
},
|
||||
channels: { signal: { allowFrom: ["+15551230000"] } },
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
plugin: {
|
||||
enabled: false,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000014",
|
||||
},
|
||||
],
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
text,
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(false);
|
||||
).toBe(text);
|
||||
});
|
||||
|
||||
it("registers reaction state when only allow-always is available", async () => {
|
||||
|
||||
@@ -8,12 +8,8 @@ import {
|
||||
type ApprovalReactionDecisionBinding,
|
||||
type ApprovalReactionTargetRecord,
|
||||
} from "openclaw/plugin-sdk/approval-reaction-runtime";
|
||||
import {
|
||||
getExecApprovalReplyMetadata,
|
||||
type ExecApprovalReplyDecision,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -25,7 +21,7 @@ import { looksLikeUuid } from "./identity.js";
|
||||
import { normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
import { getOptionalSignalRuntime } from "./runtime.js";
|
||||
|
||||
const PERSISTENT_NAMESPACE = "signal.approval-reactions.v2";
|
||||
const PERSISTENT_NAMESPACE = "signal.approval-reactions";
|
||||
const PERSISTENT_MAX_ENTRIES = 1000;
|
||||
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -62,19 +58,6 @@ type SignalApprovalReactionTarget = ApprovalReactionTargetRecord<SignalApprovalR
|
||||
route: SignalApprovalReactionRoute;
|
||||
};
|
||||
|
||||
type SignalApprovalDeliveryTarget = {
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
type SignalApprovalDeliveryResult = {
|
||||
channel?: string;
|
||||
messageId?: string | null;
|
||||
toJid?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let resolverRuntimePromise: Promise<typeof import("./approval-resolver.js")> | undefined;
|
||||
|
||||
const signalApprovalReactionTargets =
|
||||
@@ -337,7 +320,7 @@ export function addSignalApprovalReactionHintToText(params: {
|
||||
text: string;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
}): string {
|
||||
if (hasSignalApprovalReactionHintText(params.text)) {
|
||||
if (/(^|\n)React with:\s*(\n|$)/i.test(params.text)) {
|
||||
return params.text;
|
||||
}
|
||||
const hint = buildSignalApprovalReactionHint(params.allowedDecisions);
|
||||
@@ -346,8 +329,40 @@ export function addSignalApprovalReactionHintToText(params: {
|
||||
: params.text;
|
||||
}
|
||||
|
||||
function hasSignalApprovalReactionHintText(text?: string | null): boolean {
|
||||
return /(^|\n)React with:\s*(\n|$)/i.test(text ?? "");
|
||||
function normalizeApprovalDecision(value: string): ExecApprovalReplyDecision | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "always") {
|
||||
return "allow-always";
|
||||
}
|
||||
if (normalized === "allow-once" || normalized === "allow-always" || normalized === "deny") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractSignalApprovalPromptBinding(text: string): {
|
||||
approvalId: string;
|
||||
allowedDecisions: ExecApprovalReplyDecision[];
|
||||
} | null {
|
||||
const allowedDecisions: ExecApprovalReplyDecision[] = [];
|
||||
let approvalId = "";
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
if (approvalId && match[1] !== approvalId) {
|
||||
continue;
|
||||
}
|
||||
approvalId ||= match[1];
|
||||
for (const decisionText of match[2].split(/[\s|,]+/)) {
|
||||
const decision = normalizeApprovalDecision(decisionText);
|
||||
if (decision && !allowedDecisions.includes(decision)) {
|
||||
allowedDecisions.push(decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
return approvalId && allowedDecisions.length > 0 ? { approvalId, allowedDecisions } : null;
|
||||
}
|
||||
|
||||
function buildTargetRoute(params: {
|
||||
@@ -355,7 +370,6 @@ function buildTargetRoute(params: {
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
approvalId: string;
|
||||
approvalKind?: ApprovalKind;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): Extract<SignalApprovalReactionRoute, { deliveryMode: "target" }> | null {
|
||||
@@ -379,7 +393,7 @@ function buildTargetRoute(params: {
|
||||
return isSignalApprovalReactionRouteStillEnabled({
|
||||
cfg: params.cfg,
|
||||
target: {
|
||||
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(params.approvalId),
|
||||
approvalKind: resolveApprovalKindFromId(params.approvalId),
|
||||
route,
|
||||
},
|
||||
})
|
||||
@@ -387,6 +401,64 @@ function buildTargetRoute(params: {
|
||||
: null;
|
||||
}
|
||||
|
||||
export function shouldAppendSignalApprovalReactionHintForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): boolean {
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: binding.approvalId,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function appendSignalApprovalReactionHintForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): string {
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (
|
||||
!binding ||
|
||||
!shouldAppendSignalApprovalReactionHintForOutboundMessage({
|
||||
...params,
|
||||
text: params.text,
|
||||
})
|
||||
) {
|
||||
return params.text;
|
||||
}
|
||||
return addSignalApprovalReactionHintToText({
|
||||
text: params.text,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasSignalApprovalReactionApprovers(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
@@ -399,7 +471,6 @@ export function registerSignalApprovalReactionTarget(params: {
|
||||
conversationKey: string;
|
||||
messageId: string;
|
||||
approvalId: string;
|
||||
approvalKind?: ApprovalKind;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
targetAuthorKeys: readonly string[];
|
||||
route: SignalApprovalReactionRoute;
|
||||
@@ -450,7 +521,7 @@ export function registerSignalApprovalReactionTarget(params: {
|
||||
} satisfies SignalApprovalReactionRoute);
|
||||
const target: SignalApprovalReactionTarget = {
|
||||
approvalId,
|
||||
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(approvalId),
|
||||
approvalKind: resolveApprovalKindFromId(approvalId),
|
||||
allowedDecisions,
|
||||
targetAuthorKeys,
|
||||
route,
|
||||
@@ -459,142 +530,50 @@ export function registerSignalApprovalReactionTarget(params: {
|
||||
return target;
|
||||
}
|
||||
|
||||
export function addSignalApprovalReactionHintToStructuredPayload(params: {
|
||||
export function registerSignalApprovalReactionTargetForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
accountId: string;
|
||||
to: string;
|
||||
payload: ReplyPayload;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
}): ReplyPayload | null {
|
||||
const metadata = getExecApprovalReplyMetadata(params.payload);
|
||||
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
|
||||
return null;
|
||||
}
|
||||
const route = buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
agentId: metadata.agentId,
|
||||
sessionKey: metadata.sessionKey,
|
||||
});
|
||||
if (!route || !params.payload.text) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...params.payload,
|
||||
text: addSignalApprovalReactionHintToText({
|
||||
text: params.payload.text,
|
||||
allowedDecisions: metadata.allowedDecisions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function readSignalDeliveryVisibleText(result: SignalApprovalDeliveryResult): string | null {
|
||||
const meta = result.meta;
|
||||
const visibleText = meta?.signalVisibleText ?? meta?.visibleText;
|
||||
return typeof visibleText === "string" ? visibleText : null;
|
||||
}
|
||||
|
||||
function listDeliveredSignalMessageIdsWithVisibleHint(params: {
|
||||
payload: ReplyPayload;
|
||||
results: readonly SignalApprovalDeliveryResult[];
|
||||
}): string[] {
|
||||
const signalResults = params.results.filter(
|
||||
(result) => !result.channel || normalizeLowercaseStringOrEmpty(result.channel) === "signal",
|
||||
);
|
||||
const resultsWithVisibleText = signalResults.filter(
|
||||
(result) => readSignalDeliveryVisibleText(result) !== null,
|
||||
);
|
||||
const candidates = resultsWithVisibleText.length > 0 ? resultsWithVisibleText : signalResults;
|
||||
if (resultsWithVisibleText.length === 0 && candidates.length !== 1) {
|
||||
return [];
|
||||
}
|
||||
const ids = candidates
|
||||
.filter((result) =>
|
||||
resultsWithVisibleText.length > 0
|
||||
? hasSignalApprovalReactionHintText(readSignalDeliveryVisibleText(result))
|
||||
: hasSignalApprovalReactionHintText(params.payload.text),
|
||||
)
|
||||
.map((result) => normalizeOptionalString(result.messageId))
|
||||
.filter((messageId): messageId is string => Boolean(messageId && messageId !== "unknown"));
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
export function registerSignalApprovalReactionTargetForDeliveredPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
target: SignalApprovalDeliveryTarget;
|
||||
payload: ReplyPayload;
|
||||
results: readonly SignalApprovalDeliveryResult[];
|
||||
messageId: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
ttlMs?: number;
|
||||
}): boolean {
|
||||
if (normalizeLowercaseStringOrEmpty(params.target.channel) !== "signal") {
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
const metadata = getExecApprovalReplyMetadata(params.payload);
|
||||
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hasSignalApprovalReactionHintText(params.payload.text)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.target.accountId })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const conversationKey = resolveSignalApprovalConversationKey(params.target.to);
|
||||
const conversationKey = resolveSignalApprovalConversationKey(params.to);
|
||||
if (!conversationKey) {
|
||||
return false;
|
||||
}
|
||||
const route = buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.target.accountId,
|
||||
to: params.target.to,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
agentId: metadata.agentId,
|
||||
sessionKey: metadata.sessionKey,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: binding.approvalId,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params);
|
||||
if (targetAuthorKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let registered = false;
|
||||
for (const messageId of listDeliveredSignalMessageIdsWithVisibleHint({
|
||||
payload: params.payload,
|
||||
results: params.results,
|
||||
})) {
|
||||
registered =
|
||||
Boolean(
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: normalizeAccountId(params.target.accountId ?? undefined),
|
||||
conversationKey,
|
||||
messageId,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
allowedDecisions: metadata.allowedDecisions,
|
||||
targetAuthorKeys,
|
||||
route,
|
||||
routeAllowed: true,
|
||||
ttlMs: params.ttlMs,
|
||||
}),
|
||||
) || registered;
|
||||
}
|
||||
return registered;
|
||||
return Boolean(
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: params.accountId,
|
||||
conversationKey,
|
||||
messageId: params.messageId,
|
||||
approvalId: binding.approvalId,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
targetAuthorKeys: resolveSignalApprovalTargetAuthorKeys(params),
|
||||
route,
|
||||
routeAllowed: true,
|
||||
ttlMs: params.ttlMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function unregisterSignalApprovalReactionTarget(params: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Signal plugin module implements channel behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound";
|
||||
@@ -41,12 +40,10 @@ import {
|
||||
} from "./shared.js";
|
||||
type SignalSendFn = typeof import("./send.runtime.js").sendMessageSignal;
|
||||
type SignalProbe = import("./probe.js").SignalProbe;
|
||||
type SignalApprovalReactionsModule = typeof import("./approval-reactions.js");
|
||||
|
||||
let signalMonitorModulePromise: Promise<typeof import("./monitor.js")> | null = null;
|
||||
let signalProbeModulePromise: Promise<typeof import("./probe.js")> | null = null;
|
||||
let signalSendRuntimePromise: Promise<typeof import("./send.runtime.js")> | null = null;
|
||||
let signalApprovalReactionsModulePromise: Promise<SignalApprovalReactionsModule> | null = null;
|
||||
|
||||
async function loadSignalMonitorModule() {
|
||||
signalMonitorModulePromise ??= import("./monitor.js");
|
||||
@@ -63,11 +60,6 @@ async function loadSignalSendRuntime() {
|
||||
return await signalSendRuntimePromise;
|
||||
}
|
||||
|
||||
async function loadSignalApprovalReactionsModule() {
|
||||
signalApprovalReactionsModulePromise ??= import("./approval-reactions.js");
|
||||
return await signalApprovalReactionsModulePromise;
|
||||
}
|
||||
|
||||
async function resolveSignalSendContext(params: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
accountId?: string;
|
||||
@@ -110,20 +102,6 @@ type SignalMessageContextExtras = {
|
||||
deps?: { [channelId: string]: unknown };
|
||||
};
|
||||
|
||||
function attachSignalVisibleText<T extends object>(result: T, visibleText: string) {
|
||||
const meta =
|
||||
"meta" in result && result.meta && typeof result.meta === "object"
|
||||
? (result.meta as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...result,
|
||||
meta: {
|
||||
...meta,
|
||||
signalVisibleText: visibleText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const signalMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "signal",
|
||||
durableFinal: {
|
||||
@@ -246,7 +224,7 @@ async function sendFormattedSignalText(ctx: {
|
||||
textMode: "plain",
|
||||
textStyles: chunk.styles,
|
||||
});
|
||||
results.push(attachSignalVisibleText(result, chunk.text));
|
||||
results.push(result);
|
||||
}
|
||||
return attachChannelToResults("signal", results);
|
||||
}
|
||||
@@ -289,49 +267,7 @@ async function sendFormattedSignalMedia(ctx: {
|
||||
textMode: "plain",
|
||||
textStyles: formatted.styles,
|
||||
});
|
||||
return attachChannelToResult("signal", attachSignalVisibleText(result, formatted.text));
|
||||
}
|
||||
|
||||
async function registerDeliveredSignalApprovalPayloadForReactions(
|
||||
params: Parameters<NonNullable<ChannelOutboundAdapter["afterDeliverPayload"]>>[0],
|
||||
) {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.target.accountId ?? undefined,
|
||||
});
|
||||
if (!account.config.account) {
|
||||
return;
|
||||
}
|
||||
const { registerSignalApprovalReactionTargetForDeliveredPayload } =
|
||||
await loadSignalApprovalReactionsModule();
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: params.cfg,
|
||||
target: params.target,
|
||||
payload: params.payload,
|
||||
results: params.results,
|
||||
targetAuthor: account.config.account,
|
||||
});
|
||||
}
|
||||
|
||||
async function renderSignalApprovalPayloadForReactions(
|
||||
params: Parameters<NonNullable<ChannelOutboundAdapter["renderPresentation"]>>[0],
|
||||
) {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.ctx.cfg,
|
||||
accountId: params.ctx.accountId ?? undefined,
|
||||
});
|
||||
if (!account.config.account) {
|
||||
return null;
|
||||
}
|
||||
const { addSignalApprovalReactionHintToStructuredPayload } =
|
||||
await loadSignalApprovalReactionsModule();
|
||||
return addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg: params.ctx.cfg,
|
||||
accountId: params.ctx.accountId ?? undefined,
|
||||
to: params.ctx.to,
|
||||
payload: params.payload,
|
||||
targetAuthor: account.config.account,
|
||||
});
|
||||
return attachChannelToResult("signal", result);
|
||||
}
|
||||
|
||||
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
@@ -468,9 +404,6 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
payload,
|
||||
hint,
|
||||
}),
|
||||
afterDeliverPayload: async (params) =>
|
||||
await registerDeliveredSignalApprovalPayloadForReactions(params),
|
||||
renderPresentation: async (params) => await renderSignalApprovalPayloadForReactions(params),
|
||||
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) =>
|
||||
await sendFormattedSignalText({
|
||||
cfg,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
// Signal tests cover core plugin behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
@@ -7,10 +6,6 @@ import {
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalPlugin } from "./channel.js";
|
||||
import * as clientModule from "./client-adapter.js";
|
||||
import { classifySignalCliLogLine } from "./daemon.js";
|
||||
@@ -269,143 +264,6 @@ describe("signal outbound", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("registers structured approval payloads for reactions after delivery", async () => {
|
||||
clearSignalApprovalReactionTargetsForTest();
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: "+15550009999",
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-after-delivery",
|
||||
approvalSlug: "exec-aft",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const rendered = await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload,
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
});
|
||||
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
|
||||
await signalPlugin.outbound?.afterDeliverPayload?.({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: rendered!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000099",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000099",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-after-delivery",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders reaction hints only from structured approval payloads", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: "+15550009999",
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-rendered-approval",
|
||||
approvalSlug: "exec-ren",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
});
|
||||
const rendered = await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload,
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
});
|
||||
|
||||
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
expect(
|
||||
await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload: {
|
||||
text: [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-rendered-approval",
|
||||
"",
|
||||
"Reply with: /approve exec-rendered-approval allow-once|deny",
|
||||
].join("\n"),
|
||||
presentation: payload.presentation,
|
||||
},
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("declares message adapter durable text and media with receipt proofs", async () => {
|
||||
const send = vi.fn(async (_to: string, _text: string, opts: { mediaUrl?: string } = {}) => {
|
||||
const messageId = opts.mediaUrl ? "signal-media-1" : "signal-text-1";
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
|
||||
const sendMocks = vi.hoisted(() => ({
|
||||
sendMessageSignal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
|
||||
return {
|
||||
...actual,
|
||||
sendMessageSignal: sendMocks.sendMessageSignal,
|
||||
};
|
||||
});
|
||||
|
||||
const { deliverReplies } = await import("./monitor.js");
|
||||
|
||||
const botAccount = "+15550009999";
|
||||
const approver = "+15551230000";
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: botAccount,
|
||||
allowFrom: [approver],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: approver }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
async function deliverReplyPayload(payload: ReplyPayload) {
|
||||
await deliverReplies({
|
||||
cfg,
|
||||
replies: [payload],
|
||||
target: approver,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
account: botAccount,
|
||||
accountId: "default",
|
||||
runtime: { log: vi.fn() } as never,
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
textLimit: 4000,
|
||||
chunkMode: "length",
|
||||
});
|
||||
}
|
||||
|
||||
describe("Signal monitor approval reply delivery", () => {
|
||||
beforeEach(() => {
|
||||
clearSignalApprovalReactionTargetsForTest();
|
||||
sendMocks.sendMessageSignal.mockReset().mockResolvedValue({
|
||||
messageId: "1700000000200",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds reaction hints and registers structured approval replies delivered by the monitor", async () => {
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-monitor-structured",
|
||||
approvalSlug: "exec-mon",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf monitor",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
|
||||
await deliverReplyPayload(payload);
|
||||
|
||||
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
|
||||
expect(sentText).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: approver,
|
||||
messageId: "1700000000200",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: botAccount,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-monitor-structured",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: approver,
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not bind ordinary monitor replies that quote approval commands", async () => {
|
||||
const payload = {
|
||||
text: [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-monitor-quoted",
|
||||
"",
|
||||
"Reply with: /approve exec-monitor-quoted allow-once|deny",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
await deliverReplyPayload(payload);
|
||||
|
||||
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
|
||||
expect(sentText).not.toContain("React with:");
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: approver,
|
||||
messageId: "1700000000200",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: botAccount,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -39,10 +39,6 @@ import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js";
|
||||
import {
|
||||
addSignalApprovalReactionHintToStructuredPayload,
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalRpcRequest, signalCheck } from "./client-adapter.js";
|
||||
import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js";
|
||||
import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
|
||||
@@ -358,7 +354,7 @@ async function fetchAttachment(params: {
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
async function deliverReplies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
@@ -373,79 +369,32 @@ export async function deliverReplies(params: {
|
||||
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
|
||||
params;
|
||||
for (const payload of replies) {
|
||||
const deliveryResults: Array<{
|
||||
channel: "signal";
|
||||
messageId: string;
|
||||
meta: { signalVisibleText: string };
|
||||
}> = [];
|
||||
const deliveredPayload =
|
||||
addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
to: target,
|
||||
payload,
|
||||
targetAuthor: account,
|
||||
}) ?? payload;
|
||||
const reply = resolveSendableOutboundReplyParts(deliveredPayload);
|
||||
const recordDeliveryResult = (
|
||||
result: Awaited<ReturnType<typeof sendMessageSignal>>,
|
||||
visibleText: string,
|
||||
) => {
|
||||
const messageId =
|
||||
typeof result?.messageId === "string" && result.messageId.trim()
|
||||
? result.messageId.trim()
|
||||
: null;
|
||||
if (messageId) {
|
||||
deliveryResults.push({
|
||||
channel: "signal",
|
||||
messageId,
|
||||
meta: { signalVisibleText: visibleText },
|
||||
});
|
||||
}
|
||||
};
|
||||
const reply = resolveSendableOutboundReplyParts(payload);
|
||||
const delivered = await deliverTextOrMediaReply({
|
||||
payload: deliveredPayload,
|
||||
payload,
|
||||
text: reply.text,
|
||||
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
recordDeliveryResult(
|
||||
await sendMessageSignal(target, chunk, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
}),
|
||||
chunk,
|
||||
);
|
||||
await sendMessageSignal(target, chunk, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
const visibleText = caption ?? "";
|
||||
recordDeliveryResult(
|
||||
await sendMessageSignal(target, visibleText, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId,
|
||||
}),
|
||||
visibleText,
|
||||
);
|
||||
await sendMessageSignal(target, caption ?? "", {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (delivered !== "empty") {
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: params.cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: target,
|
||||
accountId,
|
||||
},
|
||||
payload: deliveredPayload,
|
||||
results: deliveryResults,
|
||||
targetAuthor: account,
|
||||
});
|
||||
runtime.log?.(`delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,73 +129,4 @@ describe("sendMessageSignal receipts", () => {
|
||||
expect(result.messageId).toBe("unknown");
|
||||
expect(result.receipt.platformMessageIds).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not add approval reactions to ordinary outbound approval-looking text", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567892 });
|
||||
const text = [
|
||||
"Here is the command you asked about:",
|
||||
"/approve exec-live-approval allow-once|deny",
|
||||
].join("\n");
|
||||
|
||||
await sendMessageSignal("+15551234567", text, {
|
||||
cfg: {
|
||||
...SIGNAL_TEST_CFG,
|
||||
channels: {
|
||||
signal: {
|
||||
...SIGNAL_TEST_CFG.channels.signal,
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(signalRpcRequestMock).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({ message: text }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add approval reactions to ordinary outbound text quoting a full prompt", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567893 });
|
||||
const text = [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-live-approval",
|
||||
"",
|
||||
"Reply with: /approve exec-live-approval allow-once|deny",
|
||||
].join("\n");
|
||||
|
||||
await sendMessageSignal("+15551234567", text, {
|
||||
cfg: {
|
||||
...SIGNAL_TEST_CFG,
|
||||
channels: {
|
||||
signal: {
|
||||
...SIGNAL_TEST_CFG.channels.signal,
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(signalRpcRequestMock).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({ message: text }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runt
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import {
|
||||
appendSignalApprovalReactionHintForOutboundMessage,
|
||||
registerSignalApprovalReactionTargetForOutboundMessage,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalRpcRequest } from "./client-adapter.js";
|
||||
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
|
||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||
@@ -180,7 +184,14 @@ export async function sendMessageSignal(
|
||||
});
|
||||
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
|
||||
const target = parseTarget(to);
|
||||
let message = text ?? "";
|
||||
const outboundText = appendSignalApprovalReactionHintForOutboundMessage({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
to,
|
||||
text: text ?? "",
|
||||
targetAuthor: account,
|
||||
});
|
||||
let message = outboundText;
|
||||
let messageFromPlaceholder = false;
|
||||
let textStyles: SignalTextStyleRange[] = [];
|
||||
const textMode = opts.textMode ?? "markdown";
|
||||
@@ -262,6 +273,14 @@ export async function sendMessageSignal(
|
||||
});
|
||||
const timestamp = result?.timestamp;
|
||||
const messageId = timestamp ? String(timestamp) : "unknown";
|
||||
registerSignalApprovalReactionTargetForOutboundMessage({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
to,
|
||||
messageId,
|
||||
text: outboundText,
|
||||
targetAuthor: account,
|
||||
});
|
||||
return {
|
||||
messageId,
|
||||
timestamp,
|
||||
|
||||
@@ -328,7 +328,6 @@ type SelectedConnectAuth = {
|
||||
authDeviceToken?: string;
|
||||
authPassword?: string;
|
||||
authApprovalRuntimeToken?: string;
|
||||
authAgentRuntimeIdentityToken?: string;
|
||||
signatureToken?: string;
|
||||
resolvedDeviceToken?: string;
|
||||
storedToken?: string;
|
||||
@@ -344,7 +343,6 @@ type StoredDeviceAuth = {
|
||||
type AssembledConnect = {
|
||||
params: ConnectParams;
|
||||
authApprovalRuntimeToken: string | undefined;
|
||||
authAgentRuntimeIdentityToken: string | undefined;
|
||||
resolvedDeviceToken: string | undefined;
|
||||
storedToken: string | undefined;
|
||||
usingStoredDeviceToken: boolean | undefined;
|
||||
@@ -432,7 +430,6 @@ export type GatewayClientOptions = {
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
instanceId?: string;
|
||||
clientName?: GatewayClientName;
|
||||
clientDisplayName?: string;
|
||||
@@ -972,24 +969,6 @@ export class GatewayClient {
|
||||
this.ws?.close(1013, "gateway starting");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.shouldFailClosedForUnsupportedAgentRuntimeIdentity({
|
||||
error: err,
|
||||
authAgentRuntimeIdentityToken: assembled.authAgentRuntimeIdentityToken,
|
||||
})
|
||||
) {
|
||||
const unsupportedIdentityError = new Error(
|
||||
"gateway rejected required agent runtime identity auth field; refusing to retry without it",
|
||||
);
|
||||
this.notifyConnectError(unsupportedIdentityError);
|
||||
this.logError(`gateway connect failed: ${unsupportedIdentityError.message}`);
|
||||
// This identity scopes model-mediated cron calls. Retrying without it
|
||||
// would turn an old/new mismatch into an unscoped operator call.
|
||||
this.closed = true;
|
||||
this.clearReconnectTimer();
|
||||
this.ws?.close(1008, "connect failed");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.shouldRetryWithoutApprovalRuntimeToken({
|
||||
error: err,
|
||||
@@ -1025,7 +1004,6 @@ export class GatewayClient {
|
||||
authDeviceToken,
|
||||
authPassword,
|
||||
authApprovalRuntimeToken,
|
||||
authAgentRuntimeIdentityToken,
|
||||
signatureToken,
|
||||
resolvedDeviceToken,
|
||||
storedToken,
|
||||
@@ -1042,15 +1020,13 @@ export class GatewayClient {
|
||||
authBootstrapToken ||
|
||||
authPassword ||
|
||||
resolvedDeviceToken ||
|
||||
authApprovalRuntimeToken ||
|
||||
authAgentRuntimeIdentityToken
|
||||
authApprovalRuntimeToken
|
||||
? {
|
||||
token: authToken,
|
||||
bootstrapToken: authBootstrapToken,
|
||||
deviceToken: authDeviceToken ?? resolvedDeviceToken,
|
||||
password: authPassword,
|
||||
approvalRuntimeToken: authApprovalRuntimeToken,
|
||||
agentRuntimeIdentityToken: authAgentRuntimeIdentityToken,
|
||||
}
|
||||
: undefined;
|
||||
const signedAtMs = Date.now();
|
||||
@@ -1093,7 +1069,6 @@ export class GatewayClient {
|
||||
}),
|
||||
},
|
||||
authApprovalRuntimeToken,
|
||||
authAgentRuntimeIdentityToken,
|
||||
resolvedDeviceToken,
|
||||
storedToken,
|
||||
usingStoredDeviceToken,
|
||||
@@ -1319,25 +1294,6 @@ export class GatewayClient {
|
||||
return message.includes("invalid connect params") && message.includes("approvalruntimetoken");
|
||||
}
|
||||
|
||||
private shouldFailClosedForUnsupportedAgentRuntimeIdentity(params: {
|
||||
error: unknown;
|
||||
authAgentRuntimeIdentityToken?: string;
|
||||
}): boolean {
|
||||
if (!params.authAgentRuntimeIdentityToken) {
|
||||
return false;
|
||||
}
|
||||
if (!(params.error instanceof GatewayClientRequestError)) {
|
||||
return false;
|
||||
}
|
||||
if (params.error.gatewayCode !== "INVALID_REQUEST") {
|
||||
return false;
|
||||
}
|
||||
const message = normalizeLowercaseStringOrEmpty(params.error.message);
|
||||
return (
|
||||
message.includes("invalid connect params") && message.includes("agentruntimeidentitytoken")
|
||||
);
|
||||
}
|
||||
|
||||
private isTrustedDeviceRetryEndpoint(): boolean {
|
||||
const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789";
|
||||
try {
|
||||
@@ -1365,9 +1321,6 @@ export class GatewayClient {
|
||||
const authApprovalRuntimeToken = this.approvalRuntimeTokenCompatibilityDisabled
|
||||
? undefined
|
||||
: normalizeOptionalString(this.opts.approvalRuntimeToken);
|
||||
const authAgentRuntimeIdentityToken = normalizeOptionalString(
|
||||
this.opts.agentRuntimeIdentityToken,
|
||||
);
|
||||
const storedAuth = this.loadStoredDeviceAuth(role);
|
||||
const storedToken = storedAuth?.token ?? null;
|
||||
const storedScopes = storedAuth?.scopes;
|
||||
@@ -1401,7 +1354,6 @@ export class GatewayClient {
|
||||
authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined,
|
||||
authPassword,
|
||||
authApprovalRuntimeToken,
|
||||
authAgentRuntimeIdentityToken,
|
||||
signatureToken: authToken ?? authBootstrapToken ?? undefined,
|
||||
resolvedDeviceToken,
|
||||
storedToken: storedToken ?? undefined,
|
||||
|
||||
@@ -26,36 +26,11 @@ const minimalAddParams = {
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
} as const;
|
||||
|
||||
const agentToolCallerScope = {
|
||||
kind: "agentTool",
|
||||
agentId: "ops",
|
||||
} as const;
|
||||
|
||||
describe("cron protocol validators", () => {
|
||||
it("accepts minimal add params", () => {
|
||||
expect(validateCronAddParams(minimalAddParams)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects public caller scope on cron admin params", () => {
|
||||
expect(validateCronListParams({ callerScope: agentToolCallerScope })).toBe(false);
|
||||
expect(validateCronGetParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);
|
||||
expect(validateCronAddParams({ ...minimalAddParams, callerScope: agentToolCallerScope })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
validateCronUpdateParams({
|
||||
id: "job-1",
|
||||
patch: { enabled: false },
|
||||
callerScope: agentToolCallerScope,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(validateCronRemoveParams({ jobId: "job-1", callerScope: agentToolCallerScope })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(validateCronRunParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);
|
||||
expect(validateCronRunsParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts current and custom session targets", () => {
|
||||
expect(
|
||||
validateCronAddParams({
|
||||
|
||||
@@ -70,7 +70,6 @@ export const ConnectParamsSchema = Type.Object(
|
||||
deviceToken: Type.Optional(Type.String()),
|
||||
password: Type.Optional(Type.String()),
|
||||
approvalRuntimeToken: Type.Optional(Type.String()),
|
||||
agentRuntimeIdentityToken: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
import { copyPluginToolMeta } from "../plugins/tools.js";
|
||||
import { bindAbortRelay } from "../utils/fetch-timeout.js";
|
||||
import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "./channel-tools.js";
|
||||
|
||||
function throwAbortError(): never {
|
||||
|
||||
@@ -73,18 +73,6 @@ export {
|
||||
consumePreExecutionBlockedToolCall,
|
||||
peekAdjustedParamsForToolCall,
|
||||
} from "./agent-tools.before-tool-call.state.js";
|
||||
import {
|
||||
BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS,
|
||||
BEFORE_TOOL_CALL_HOOK_CONTEXT,
|
||||
BEFORE_TOOL_CALL_SOURCE_TOOL,
|
||||
BEFORE_TOOL_CALL_WRAPPED,
|
||||
type BeforeToolCallDiagnosticOptions,
|
||||
} from "./before-tool-call-metadata.js";
|
||||
export {
|
||||
copyBeforeToolCallHookMarker,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
setBeforeToolCallDiagnosticsEnabled,
|
||||
} from "./before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta, getChannelAgentToolMeta } from "./channel-tools.js";
|
||||
import {
|
||||
getCodeModeExecBeforeHookMetadata,
|
||||
@@ -95,7 +83,6 @@ import {
|
||||
} from "./code-mode-control-tools.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
import { normalizeToolName } from "./tool-policy.js";
|
||||
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";
|
||||
import { getToolTerminalPresentation } from "./tool-terminal-presentation.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
@@ -225,6 +212,10 @@ export function hasBeforeToolCallPolicy(): boolean {
|
||||
}
|
||||
|
||||
const log = createSubsystemLogger("agents/tools");
|
||||
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
|
||||
const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions");
|
||||
const BEFORE_TOOL_CALL_SOURCE_TOOL = Symbol("beforeToolCallSourceTool");
|
||||
const BEFORE_TOOL_CALL_HOOK_CONTEXT = Symbol("beforeToolCallHookContext");
|
||||
const BEFORE_TOOL_CALL_HOOK_FAILURE_REASON =
|
||||
"Tool call blocked because before_tool_call hook failed";
|
||||
const MAX_TRACKED_ADJUSTED_PARAMS = 1024;
|
||||
@@ -1567,13 +1558,12 @@ export function wrapToolWithBeforeToolCallHook(
|
||||
};
|
||||
copyPluginToolMeta(tool, wrappedTool);
|
||||
copyChannelAgentToolMeta(tool as never, wrappedTool as never);
|
||||
copyToolTerminalPresentation(tool, wrappedTool);
|
||||
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
});
|
||||
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS, {
|
||||
value: hookOptions satisfies BeforeToolCallDiagnosticOptions,
|
||||
value: hookOptions,
|
||||
enumerable: false,
|
||||
});
|
||||
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_SOURCE_TOOL, {
|
||||
@@ -1587,6 +1577,21 @@ export function wrapToolWithBeforeToolCallHook(
|
||||
return wrappedTool;
|
||||
}
|
||||
|
||||
/** Return true when a tool already carries the before_tool_call wrapper marker. */
|
||||
export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean {
|
||||
const taggedTool = tool as unknown as Record<symbol, unknown>;
|
||||
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
|
||||
}
|
||||
|
||||
/** Toggle diagnostic event emission on an existing before_tool_call wrapper. */
|
||||
export function setBeforeToolCallDiagnosticsEnabled(tool: AnyAgentTool, enabled: boolean): void {
|
||||
const taggedTool = tool as unknown as Record<symbol, unknown>;
|
||||
const options = taggedTool[BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS];
|
||||
if (options && typeof options === "object" && "emitDiagnostics" in options) {
|
||||
(options as { emitDiagnostics: boolean }).emitDiagnostics = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild a before_tool_call wrapper while preserving the original source tool. */
|
||||
export function rewrapToolWithBeforeToolCallHook(
|
||||
tool: AnyAgentTool,
|
||||
@@ -1613,10 +1618,33 @@ export function rewrapToolWithBeforeToolCallHook(
|
||||
delete (rewrapSource as unknown as Record<symbol, unknown>)[BEFORE_TOOL_CALL_WRAPPED];
|
||||
copyPluginToolMeta(tool, rewrapSource);
|
||||
copyChannelAgentToolMeta(tool as never, rewrapSource as never);
|
||||
copyToolTerminalPresentation(tool, rewrapSource);
|
||||
return wrapToolWithBeforeToolCallHook(rewrapSource, ctx ?? preservedContext, options);
|
||||
}
|
||||
|
||||
/** Copy before_tool_call marker metadata when another wrapper replaces a tool. */
|
||||
export function copyBeforeToolCallHookMarker(source: AnyAgentTool, target: AnyAgentTool): void {
|
||||
if (!isToolWrappedWithBeforeToolCallHook(source)) {
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(target, BEFORE_TOOL_CALL_WRAPPED, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
});
|
||||
const taggedSource = source as unknown as Record<symbol, unknown>;
|
||||
const sourceTool = taggedSource[BEFORE_TOOL_CALL_SOURCE_TOOL];
|
||||
if (sourceTool && typeof sourceTool === "object") {
|
||||
Object.defineProperty(target, BEFORE_TOOL_CALL_SOURCE_TOOL, {
|
||||
value: sourceTool,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
const hookContext = taggedSource[BEFORE_TOOL_CALL_HOOK_CONTEXT];
|
||||
Object.defineProperty(target, BEFORE_TOOL_CALL_HOOK_CONTEXT, {
|
||||
value: hookContext,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
|
||||
function recordPreExecutionBlockedToolCall(toolCallId?: string, runId?: string): void {
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { copyPluginToolMeta } from "../plugins/tools.js";
|
||||
import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js";
|
||||
/**
|
||||
* Adjusts exec/process tool descriptions for long-running follow-up behavior.
|
||||
* Cron-aware runs can point models at scheduled follow-ups; cronless runs keep
|
||||
@@ -6,7 +7,6 @@ import { copyPluginToolMeta } from "../plugins/tools.js";
|
||||
*/
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { describeExecTool, describeProcessTool } from "./bash-tools.descriptions.js";
|
||||
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "./channel-tools.js";
|
||||
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
normalizeToolParameterSchema,
|
||||
type ToolParameterSchemaOptions,
|
||||
} from "./agent-tools-parameter-schema.js";
|
||||
import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "./channel-tools.js";
|
||||
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
|
||||
export type BeforeToolCallDiagnosticOptions = {
|
||||
emitDiagnostics: boolean;
|
||||
};
|
||||
|
||||
export const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
|
||||
export const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions");
|
||||
export const BEFORE_TOOL_CALL_SOURCE_TOOL = Symbol("beforeToolCallSourceTool");
|
||||
export const BEFORE_TOOL_CALL_HOOK_CONTEXT = Symbol("beforeToolCallHookContext");
|
||||
|
||||
/** Return true when a tool already carries the before_tool_call wrapper marker. */
|
||||
export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean {
|
||||
const taggedTool = tool as unknown as Record<symbol, unknown>;
|
||||
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
|
||||
}
|
||||
|
||||
/** Toggle diagnostic event emission on an existing before_tool_call wrapper. */
|
||||
export function setBeforeToolCallDiagnosticsEnabled(tool: AnyAgentTool, enabled: boolean): void {
|
||||
const taggedTool = tool as unknown as Record<symbol, unknown>;
|
||||
const options = taggedTool[BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS];
|
||||
if (options && typeof options === "object" && "emitDiagnostics" in options) {
|
||||
(options as BeforeToolCallDiagnosticOptions).emitDiagnostics = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy before_tool_call marker metadata when another wrapper replaces a tool. */
|
||||
export function copyBeforeToolCallHookMarker(source: AnyAgentTool, target: AnyAgentTool): void {
|
||||
if (!isToolWrappedWithBeforeToolCallHook(source)) {
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(target, BEFORE_TOOL_CALL_WRAPPED, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
});
|
||||
const taggedSource = source as unknown as Record<symbol, unknown>;
|
||||
const sourceTool = taggedSource[BEFORE_TOOL_CALL_SOURCE_TOOL];
|
||||
if (sourceTool && typeof sourceTool === "object") {
|
||||
Object.defineProperty(target, BEFORE_TOOL_CALL_SOURCE_TOOL, {
|
||||
value: sourceTool,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
const hookContext = taggedSource[BEFORE_TOOL_CALL_HOOK_CONTEXT];
|
||||
Object.defineProperty(target, BEFORE_TOOL_CALL_HOOK_CONTEXT, {
|
||||
value: hookContext,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
@@ -43,7 +43,6 @@ import { createAgentsListTool } from "./tools/agents-list-tool.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
import { createCronTool, type CronCreatorToolAllowlistEntry } from "./tools/cron-tool.js";
|
||||
import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js";
|
||||
import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-context.js";
|
||||
import { createGatewayTool } from "./tools/gateway-tool.js";
|
||||
import {
|
||||
createCreateGoalTool,
|
||||
@@ -577,17 +576,10 @@ export function createOpenClawTools(
|
||||
options?.recordToolPrepStage?.("openclaw-tools:plugin-tools");
|
||||
}
|
||||
|
||||
const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId;
|
||||
const gatewayCallerIdentity =
|
||||
hookAgentId && options?.agentSessionKey?.trim()
|
||||
? { agentId: hookAgentId, sessionKey: options.agentSessionKey.trim() }
|
||||
: undefined;
|
||||
const wrapGatewayCallerIdentity = (tool: AnyAgentTool) =>
|
||||
wrapToolWithGatewayCallerIdentity(tool, gatewayCallerIdentity);
|
||||
|
||||
if (options?.wrapBeforeToolCallHook === false) {
|
||||
return allTools.map(wrapGatewayCallerIdentity);
|
||||
return allTools;
|
||||
}
|
||||
const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId;
|
||||
const defaultHookContext: HookContext = {
|
||||
...(hookAgentId ? { agentId: hookAgentId } : {}),
|
||||
...(resolvedConfig ? { config: resolvedConfig } : {}),
|
||||
@@ -601,13 +593,11 @@ export function createOpenClawTools(
|
||||
...options?.beforeToolCallHookContext,
|
||||
};
|
||||
options?.recordToolPrepStage?.("openclaw-tools:tool-hooks");
|
||||
return allTools
|
||||
.map((tool) =>
|
||||
isToolWrappedWithBeforeToolCallHook(tool)
|
||||
? tool
|
||||
: wrapToolWithBeforeToolCallHook(tool, hookContext),
|
||||
)
|
||||
.map(wrapGatewayCallerIdentity);
|
||||
return allTools.map((tool) =>
|
||||
isToolWrappedWithBeforeToolCallHook(tool)
|
||||
? tool
|
||||
: wrapToolWithBeforeToolCallHook(tool, hookContext),
|
||||
);
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { ProviderRuntimePluginHandle } from "../../plugins/provider-hook-runtime.js";
|
||||
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
|
||||
import { copyPluginToolMeta } from "../../plugins/tools.js";
|
||||
import { copyBeforeToolCallHookMarker } from "../before-tool-call-metadata.js";
|
||||
import { copyBeforeToolCallHookMarker } from "../agent-tools.before-tool-call.js";
|
||||
import { copyChannelAgentToolMeta } from "../channel-tools.js";
|
||||
import {
|
||||
logProviderToolSchemaDiagnostics,
|
||||
|
||||
@@ -6,13 +6,9 @@ const { callGatewayToolMock } = vi.hoisted(() => ({
|
||||
callGatewayToolMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../agent-scope.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveSessionAgentId: actual.resolveSessionAgentId,
|
||||
};
|
||||
});
|
||||
vi.mock("../agent-scope.js", () => ({
|
||||
resolveSessionAgentId: () => "agent-123",
|
||||
}));
|
||||
|
||||
import { getToolTerminalPresentation } from "../tool-terminal-presentation.js";
|
||||
import { createCronTool } from "./cron-tool.js";
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock("../agent-scope.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveSessionAgentId: actual.resolveSessionAgentId,
|
||||
resolveSessionAgentId: () => "agent-123",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -182,10 +182,7 @@ describe("cron tool", () => {
|
||||
it("allows scoped isolated cron runs to remove the current job", async () => {
|
||||
// Self-removal scope lets a cron-triggered run clean up its own schedule
|
||||
// without granting broad cron mutation access.
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "main",
|
||||
selfRemoveOnlyJobId: "job-current",
|
||||
});
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
|
||||
await tool.execute("call-self-remove", {
|
||||
action: "remove",
|
||||
@@ -197,10 +194,7 @@ describe("cron tool", () => {
|
||||
});
|
||||
|
||||
it("denies scoped isolated cron runs from removing another job", async () => {
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "main",
|
||||
selfRemoveOnlyJobId: "job-current",
|
||||
});
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
|
||||
await expect(
|
||||
tool.execute("call-remove-other", {
|
||||
@@ -221,10 +215,7 @@ describe("cron tool", () => {
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
});
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "main",
|
||||
selfRemoveOnlyJobId: "job-current",
|
||||
});
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
|
||||
const result = await tool.execute("call-self-runs", {
|
||||
action: "runs",
|
||||
@@ -247,10 +238,7 @@ describe("cron tool", () => {
|
||||
["another job", { action: "runs", jobId: "job-other" }],
|
||||
["missing job id", { action: "runs" }],
|
||||
])("denies scoped isolated cron runs from reading %s run history", async (_label, args) => {
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "main",
|
||||
selfRemoveOnlyJobId: "job-current",
|
||||
});
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
|
||||
await expect(tool.execute("call-runs-denied", args)).rejects.toThrow(
|
||||
"Cron tool is restricted to the current cron job.",
|
||||
@@ -293,10 +281,7 @@ describe("cron tool", () => {
|
||||
|
||||
it("allows scoped isolated cron runs to get the current job", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ id: "job-current", name: "current" });
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "main",
|
||||
selfRemoveOnlyJobId: "job-current",
|
||||
});
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
|
||||
const result = await tool.execute("call-get", {
|
||||
action: "get",
|
||||
@@ -344,6 +329,7 @@ describe("cron tool", () => {
|
||||
|
||||
const result = await tool.execute("call-list", {
|
||||
action: "list",
|
||||
agentId: "other-agent",
|
||||
includeDisabled: true,
|
||||
});
|
||||
|
||||
@@ -462,44 +448,22 @@ describe("cron tool", () => {
|
||||
});
|
||||
|
||||
const params = expectSingleGatewayCallMethod("cron.list");
|
||||
expect(params).toEqual({
|
||||
includeDisabled: false,
|
||||
compact: true,
|
||||
agentId: "agent-123",
|
||||
});
|
||||
expect(params).toEqual({ includeDisabled: false, compact: true, agentId: "agent-123" });
|
||||
});
|
||||
|
||||
it("rejects explicit cron list agent id outside the requester session", async () => {
|
||||
it("prefers explicit cron list agent id over the requester session", async () => {
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "agent:agent-123:telegram:direct:channing",
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-list-explicit", {
|
||||
action: "list",
|
||||
agentId: "ops",
|
||||
includeDisabled: true,
|
||||
}),
|
||||
).rejects.toThrow("cron list agentId must match the calling agent");
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves explicit agentId for sessionless cron list callers", async () => {
|
||||
const tool = createTestCronTool();
|
||||
|
||||
await tool.execute("call-sessionless-list", {
|
||||
await tool.execute("call-list-explicit", {
|
||||
action: "list",
|
||||
agentId: "worker",
|
||||
agentId: "ops",
|
||||
includeDisabled: true,
|
||||
});
|
||||
|
||||
const params = expectSingleGatewayCallMethod("cron.list");
|
||||
expect(params).toEqual({
|
||||
includeDisabled: true,
|
||||
compact: true,
|
||||
agentId: "worker",
|
||||
});
|
||||
expect(params).toEqual({ includeDisabled: true, compact: true, agentId: "ops" });
|
||||
});
|
||||
|
||||
it("retries cron.list without compact for older gateways", async () => {
|
||||
@@ -519,18 +483,11 @@ describe("cron tool", () => {
|
||||
|
||||
expect(readGatewayCall(0)).toEqual({
|
||||
method: "cron.list",
|
||||
params: {
|
||||
includeDisabled: false,
|
||||
compact: true,
|
||||
agentId: "agent-123",
|
||||
},
|
||||
params: { includeDisabled: false, compact: true, agentId: "agent-123" },
|
||||
});
|
||||
expect(readGatewayCall(1)).toEqual({
|
||||
method: "cron.list",
|
||||
params: {
|
||||
includeDisabled: false,
|
||||
agentId: "agent-123",
|
||||
},
|
||||
params: { includeDisabled: false, agentId: "agent-123" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -787,10 +744,7 @@ describe("cron tool", () => {
|
||||
id: "job-legacy",
|
||||
});
|
||||
|
||||
expect(readGatewayCall().params).toEqual({
|
||||
id: "job-primary",
|
||||
mode: "due",
|
||||
});
|
||||
expect(readGatewayCall().params).toEqual({ id: "job-primary", mode: "due" });
|
||||
});
|
||||
|
||||
it("supports due-only run mode", async () => {
|
||||
@@ -801,10 +755,7 @@ describe("cron tool", () => {
|
||||
runMode: "due",
|
||||
});
|
||||
|
||||
expect(readGatewayCall().params).toEqual({
|
||||
id: "job-due",
|
||||
mode: "due",
|
||||
});
|
||||
expect(readGatewayCall().params).toEqual({ id: "job-due", mode: "due" });
|
||||
});
|
||||
|
||||
it("supports force run mode", async () => {
|
||||
@@ -815,10 +766,7 @@ describe("cron tool", () => {
|
||||
runMode: "force",
|
||||
});
|
||||
|
||||
expect(readGatewayCall().params).toEqual({
|
||||
id: "job-force",
|
||||
mode: "force",
|
||||
});
|
||||
expect(readGatewayCall().params).toEqual({ id: "job-force", mode: "force" });
|
||||
});
|
||||
|
||||
it("normalizes cron.add job payloads", async () => {
|
||||
@@ -846,43 +794,18 @@ describe("cron tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects null agentId on add from the scoped agent cron tool", async () => {
|
||||
it("does not default agentId when job.agentId is null", async () => {
|
||||
const tool = createTestCronTool({ agentSessionKey: "main" });
|
||||
await expect(
|
||||
tool.execute("call-null", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "wake-up",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
agentId: null,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("cron job agentId must match the calling agent");
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves explicit agentId for sessionless cron add callers", async () => {
|
||||
const tool = createTestCronTool();
|
||||
|
||||
await tool.execute("call-sessionless-add", {
|
||||
await tool.execute("call-null", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "worker job",
|
||||
name: "wake-up",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
agentId: "worker",
|
||||
agentId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const params = expectSingleGatewayCallMethod("cron.add");
|
||||
expect(params).toMatchObject({
|
||||
name: "worker job",
|
||||
agentId: "worker",
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
});
|
||||
expect(params).not.toHaveProperty("callerScope");
|
||||
expect(readGatewayCall().params?.agentId).toBeNull();
|
||||
});
|
||||
|
||||
it("infers session agentId when job.agentId is omitted", async () => {
|
||||
@@ -905,71 +828,6 @@ describe("cron tool", () => {
|
||||
).resolves.toBe("agent-123");
|
||||
});
|
||||
|
||||
it("accepts matching explicit agentId on add", async () => {
|
||||
await expect(
|
||||
executeAddAndReadAgentId({
|
||||
callId: "call-matching-agent-id",
|
||||
agentSessionKey: "agent:agent-123:telegram:direct:channing",
|
||||
includeAgentId: true,
|
||||
agentId: "agent-123",
|
||||
}),
|
||||
).resolves.toBe("agent-123");
|
||||
});
|
||||
|
||||
it("rejects foreign explicit agentId on add", async () => {
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "agent:agent-123:telegram:direct:channing",
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-foreign-agent-id", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "foreign",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
agentId: "worker",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("cron job agentId must match the calling agent");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects foreign agent-prefixed session refs on add", async () => {
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "agent:agent-123:telegram:direct:channing",
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-foreign-session-ref", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "foreign session",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
sessionTarget: "session:agent:worker:telegram:direct:alice",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("cron sessionTarget must match the calling agent");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not forward model-supplied callerScope", async () => {
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "agent:agent-123:telegram:direct:channing",
|
||||
});
|
||||
|
||||
await tool.execute("call-spoofed-caller-scope", {
|
||||
action: "remove",
|
||||
jobId: "job-1",
|
||||
callerScope: { kind: "agentTool", agentId: "worker" },
|
||||
});
|
||||
|
||||
expect(readGatewayCall().params).toEqual({
|
||||
id: "job-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through failureAlert=false for add", async () => {
|
||||
const tool = createTestCronTool();
|
||||
await tool.execute("call-disable-alerts-add", {
|
||||
@@ -1373,23 +1231,23 @@ describe("cron tool", () => {
|
||||
expect(text).not.toContain("Recent context:");
|
||||
});
|
||||
|
||||
it("rejects explicit agentId null on add", async () => {
|
||||
it("preserves explicit agentId null on add", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const tool = createTestCronTool({ agentSessionKey: "main" });
|
||||
await expect(
|
||||
tool.execute("call6", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
agentId: null,
|
||||
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("cron job agentId must match the calling agent");
|
||||
await tool.execute("call6", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
agentId: null,
|
||||
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
||||
},
|
||||
});
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
const call = readGatewayCall();
|
||||
expect(call.method).toBe("cron.add");
|
||||
expect(call.params?.agentId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not infer delivery from raw session-key fragments without delivery context", async () => {
|
||||
@@ -1909,55 +1767,6 @@ describe("cron tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects agentId retargeting on update", async () => {
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "agent:agent-123:telegram:direct:channing",
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-update-agent-id", {
|
||||
action: "update",
|
||||
id: "job-1",
|
||||
patch: { agentId: "worker" },
|
||||
}),
|
||||
).rejects.toThrow("cron patch agentId cannot be changed");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows unscoped operator cron.update agentId retargeting", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
const tool = createTestCronTool();
|
||||
|
||||
await tool.execute("call-unscoped-update-agent-id", {
|
||||
action: "update",
|
||||
id: "job-1",
|
||||
patch: { agentId: "worker" },
|
||||
});
|
||||
|
||||
const params = expectSingleGatewayCallMethod("cron.update") as
|
||||
| { id?: string; patch?: { agentId?: string } }
|
||||
| undefined;
|
||||
expect(params).toEqual({
|
||||
id: "job-1",
|
||||
patch: { agentId: "worker" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects foreign sessionTarget retargeting on update", async () => {
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "agent:agent-123:telegram:direct:channing",
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-update-session-target", {
|
||||
action: "update",
|
||||
id: "job-1",
|
||||
patch: { sessionTarget: "session:agent:worker:telegram:direct:alice" },
|
||||
}),
|
||||
).rejects.toThrow("cron sessionTarget must match the calling agent");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers additional flat patch params for update action", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
*/
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { Type, type TSchema } from "typebox";
|
||||
import { getRuntimeConfig, type OpenClawConfig } from "../../config/config.js";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import { resolveCronCreationDelivery } from "../../cron/delivery-context.js";
|
||||
import { assertCronDeliveryInputNonBlankFields } from "../../cron/delivery-target-validation.js";
|
||||
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
|
||||
import type { CronDelivery } from "../../cron/types.js";
|
||||
import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js";
|
||||
import { GatewayClientRequestError } from "../../gateway/client.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
|
||||
import { extractTextFromChatContent } from "../../shared/chat-content.js";
|
||||
import { isRecord, truncateUtf16Safe } from "../../utils.js";
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
isEmptyRecoveredCronPatch,
|
||||
recoverCronObjectFromFlatParams,
|
||||
} from "./cron-tool-canonicalize.js";
|
||||
import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js";
|
||||
import { gatewayCallOptionSchemaProperties } from "./gateway-schema.js";
|
||||
import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js";
|
||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||
@@ -352,11 +350,6 @@ type CronToolOptions = {
|
||||
selfRemoveOnlyJobId?: string;
|
||||
};
|
||||
|
||||
type CronToolCallerScope = {
|
||||
kind: "agentTool";
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
export type CronCreatorToolAllowlistEntry =
|
||||
| string
|
||||
| {
|
||||
@@ -548,9 +541,7 @@ async function capCronAgentTurnUpdatePatchToolsAllow(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await params.callGateway("cron.get", params.gatewayOpts, {
|
||||
id: params.id,
|
||||
});
|
||||
const existing = await params.callGateway("cron.get", params.gatewayOpts, { id: params.id });
|
||||
const existingPayload = isRecord(existing) ? existing.payload : undefined;
|
||||
const existingPayloadKind = readCronPayloadKind(existingPayload);
|
||||
if (!patchRequestsAgentTurn && existingPayloadKind !== "agentTurn") {
|
||||
@@ -585,70 +576,6 @@ function readCronJobIdParam(params: Record<string, unknown>) {
|
||||
return readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
||||
}
|
||||
|
||||
function resolveCronToolCallerScope(
|
||||
opts: CronToolOptions | undefined,
|
||||
cfg: OpenClawConfig,
|
||||
): CronToolCallerScope | undefined {
|
||||
const sessionKey = opts?.agentSessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: "agentTool",
|
||||
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
|
||||
};
|
||||
}
|
||||
|
||||
function readCronToolAgentId(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? normalizeAgentId(value) : undefined;
|
||||
}
|
||||
|
||||
function readAgentIdFromCronToolSessionRef(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim()
|
||||
? parseAgentSessionKey(value.trim())?.agentId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readAgentIdFromCronToolSessionTarget(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("session:")) {
|
||||
return undefined;
|
||||
}
|
||||
return readAgentIdFromCronToolSessionRef(trimmed.slice("session:".length));
|
||||
}
|
||||
|
||||
function assertCronToolAgentFieldMatchesScope(params: {
|
||||
value: unknown;
|
||||
field: string;
|
||||
callerScope: CronToolCallerScope;
|
||||
}): void {
|
||||
if (params.value === undefined) {
|
||||
return;
|
||||
}
|
||||
const agentId = readCronToolAgentId(params.value);
|
||||
if (agentId && agentId === params.callerScope.agentId) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${params.field} must match the calling agent`);
|
||||
}
|
||||
|
||||
function assertCronToolSessionRefsMatchScope(
|
||||
value: Record<string, unknown>,
|
||||
callerScope: CronToolCallerScope,
|
||||
): void {
|
||||
const sessionAgentId = readAgentIdFromCronToolSessionRef(value.sessionKey);
|
||||
if (sessionAgentId && normalizeAgentId(sessionAgentId) !== callerScope.agentId) {
|
||||
throw new Error("cron sessionKey must match the calling agent");
|
||||
}
|
||||
const sessionTargetAgentId = readAgentIdFromCronToolSessionTarget(value.sessionTarget);
|
||||
if (sessionTargetAgentId && normalizeAgentId(sessionTargetAgentId) !== callerScope.agentId) {
|
||||
throw new Error("cron sessionTarget must match the calling agent");
|
||||
}
|
||||
}
|
||||
|
||||
const CRON_SELF_REMOVE_SCOPE_ERROR = "Cron tool is restricted to the current cron job.";
|
||||
|
||||
function readCronSelfRemoveOnlyJobId(opts: CronToolOptions | undefined) {
|
||||
@@ -932,360 +859,325 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me
|
||||
...parsedGatewayOpts,
|
||||
timeoutMs: parsedGatewayOpts.timeoutMs ?? 60_000,
|
||||
};
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
const callerScope = resolveCronToolCallerScope(opts, runtimeConfig);
|
||||
const callerIdentity =
|
||||
callerScope && opts?.agentSessionKey?.trim()
|
||||
? { agentId: callerScope.agentId, sessionKey: opts.agentSessionKey.trim() }
|
||||
: undefined;
|
||||
|
||||
return await withGatewayToolCallerIdentity(callerIdentity, async () => {
|
||||
switch (action) {
|
||||
case "status": {
|
||||
const result = await callGateway("cron.status", gatewayOpts, {});
|
||||
return jsonResult(
|
||||
readCronSelfRemoveOnlyJobId(opts)
|
||||
? filterCronStatusResultForSelfScope(result)
|
||||
: result,
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
const selfRemoveOnlyJobId = readCronSelfRemoveOnlyJobId(opts);
|
||||
const explicitAgentId = readCronToolAgentId(params.agentId);
|
||||
if (callerScope && explicitAgentId && explicitAgentId !== callerScope.agentId) {
|
||||
throw new Error("cron list agentId must match the calling agent");
|
||||
}
|
||||
const listAgentId = callerScope?.agentId ?? explicitAgentId;
|
||||
const includeDisabled = Boolean(params.includeDisabled);
|
||||
let offset = 0;
|
||||
let result: unknown;
|
||||
let shouldContinue = true;
|
||||
let useCompactList = true;
|
||||
while (shouldContinue) {
|
||||
try {
|
||||
result = await callGateway("cron.list", gatewayOpts, {
|
||||
includeDisabled,
|
||||
...(useCompactList ? { compact: true } : {}),
|
||||
...(listAgentId ? { agentId: listAgentId } : {}),
|
||||
...(selfRemoveOnlyJobId ? { limit: 200, offset } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!useCompactList || !isOlderGatewayWithoutCompactCronList(error)) {
|
||||
throw error;
|
||||
}
|
||||
// Protocol v4 gateways predating compact reject the additive field.
|
||||
// Retry without it for mixed-version correctness; remove at the next protocol break.
|
||||
useCompactList = false;
|
||||
continue;
|
||||
switch (action) {
|
||||
case "status": {
|
||||
const result = await callGateway("cron.status", gatewayOpts, {});
|
||||
return jsonResult(
|
||||
readCronSelfRemoveOnlyJobId(opts) ? filterCronStatusResultForSelfScope(result) : result,
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
const cfg = getRuntimeConfig();
|
||||
const selfRemoveOnlyJobId = readCronSelfRemoveOnlyJobId(opts);
|
||||
const listAgentId = selfRemoveOnlyJobId
|
||||
? opts?.agentSessionKey?.trim()
|
||||
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
|
||||
: undefined
|
||||
: typeof params.agentId === "string" && params.agentId.trim()
|
||||
? params.agentId.trim()
|
||||
: opts?.agentSessionKey
|
||||
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
|
||||
: undefined;
|
||||
const includeDisabled = Boolean(params.includeDisabled);
|
||||
let offset = 0;
|
||||
let result: unknown;
|
||||
let shouldContinue = true;
|
||||
let useCompactList = true;
|
||||
while (shouldContinue) {
|
||||
try {
|
||||
result = await callGateway("cron.list", gatewayOpts, {
|
||||
includeDisabled,
|
||||
...(useCompactList ? { compact: true } : {}),
|
||||
agentId: listAgentId,
|
||||
...(selfRemoveOnlyJobId ? { limit: 200, offset } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!useCompactList || !isOlderGatewayWithoutCompactCronList(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (!selfRemoveOnlyJobId || cronListResultHasJob(result, selfRemoveOnlyJobId)) {
|
||||
// Protocol v4 gateways predating compact reject the additive field.
|
||||
// Retry without it for mixed-version correctness; remove at the next protocol break.
|
||||
useCompactList = false;
|
||||
continue;
|
||||
}
|
||||
if (!selfRemoveOnlyJobId || cronListResultHasJob(result, selfRemoveOnlyJobId)) {
|
||||
shouldContinue = false;
|
||||
} else {
|
||||
const nextOffset = readCronListNextOffset(result, offset);
|
||||
if (nextOffset === undefined) {
|
||||
shouldContinue = false;
|
||||
} else {
|
||||
const nextOffset = readCronListNextOffset(result, offset);
|
||||
if (nextOffset === undefined) {
|
||||
shouldContinue = false;
|
||||
} else {
|
||||
offset = nextOffset;
|
||||
}
|
||||
offset = nextOffset;
|
||||
}
|
||||
}
|
||||
return jsonResult(
|
||||
selfRemoveOnlyJobId
|
||||
? filterCronListResultToJobId(result, selfRemoveOnlyJobId)
|
||||
: result,
|
||||
);
|
||||
}
|
||||
case "get": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
return jsonResult(
|
||||
await callGateway("cron.get", gatewayOpts, {
|
||||
id,
|
||||
}),
|
||||
);
|
||||
return jsonResult(
|
||||
selfRemoveOnlyJobId ? filterCronListResultToJobId(result, selfRemoveOnlyJobId) : result,
|
||||
);
|
||||
}
|
||||
case "get": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
case "add": {
|
||||
// Flat-params recovery: non-frontier models (e.g. Grok) sometimes flatten
|
||||
// job properties to the top level alongside `action` instead of nesting
|
||||
// them inside `job`. When `params.job` is missing or empty, reconstruct
|
||||
// a synthetic job object from any recognised top-level job fields.
|
||||
// See: https://github.com/openclaw/openclaw/issues/11310
|
||||
if (isMissingOrEmptyObject(params.job)) {
|
||||
const synthetic = recoverCronObjectFromFlatParams(params);
|
||||
// Only use the synthetic job if at least one meaningful field is present
|
||||
// (schedule, payload, message, or text are the minimum signals that the
|
||||
// LLM intended to create a job).
|
||||
if (synthetic.found && hasCronCreateSignal(synthetic.value)) {
|
||||
params.job = synthetic.value;
|
||||
}
|
||||
return jsonResult(await callGateway("cron.get", gatewayOpts, { id }));
|
||||
}
|
||||
case "add": {
|
||||
// Flat-params recovery: non-frontier models (e.g. Grok) sometimes flatten
|
||||
// job properties to the top level alongside `action` instead of nesting
|
||||
// them inside `job`. When `params.job` is missing or empty, reconstruct
|
||||
// a synthetic job object from any recognised top-level job fields.
|
||||
// See: https://github.com/openclaw/openclaw/issues/11310
|
||||
if (isMissingOrEmptyObject(params.job)) {
|
||||
const synthetic = recoverCronObjectFromFlatParams(params);
|
||||
// Only use the synthetic job if at least one meaningful field is present
|
||||
// (schedule, payload, message, or text are the minimum signals that the
|
||||
// LLM intended to create a job).
|
||||
if (synthetic.found && hasCronCreateSignal(synthetic.value)) {
|
||||
params.job = synthetic.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.job || typeof params.job !== "object") {
|
||||
throw new Error("job required");
|
||||
}
|
||||
const canonicalJob = canonicalizeCronToolObject(params.job as Record<string, unknown>);
|
||||
assertNoCronCommandPayload(canonicalJob);
|
||||
assertCronDeliveryInputNonBlankFields(canonicalJob.delivery);
|
||||
const job =
|
||||
normalizeCronJobCreate(canonicalJob, {
|
||||
sessionContext: { sessionKey: opts?.agentSessionKey },
|
||||
}) ?? canonicalJob;
|
||||
capCronAgentTurnJobToolsAllow(job, opts?.creatorToolAllowlist);
|
||||
if (job && typeof job === "object") {
|
||||
const { mainKey, alias } = resolveMainSessionAlias(runtimeConfig);
|
||||
const resolvedSessionKey = opts?.agentSessionKey
|
||||
? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey })
|
||||
: undefined;
|
||||
if (callerScope) {
|
||||
assertCronToolAgentFieldMatchesScope({
|
||||
value: (job as { agentId?: unknown }).agentId,
|
||||
field: "cron job agentId",
|
||||
callerScope,
|
||||
});
|
||||
(job as { agentId?: string }).agentId = callerScope.agentId;
|
||||
assertCronToolSessionRefsMatchScope(job as Record<string, unknown>, callerScope);
|
||||
}
|
||||
const sessionTarget = normalizeLowercaseStringOrEmpty(
|
||||
(job as { sessionTarget?: unknown }).sessionTarget,
|
||||
);
|
||||
if (!("sessionKey" in job) && resolvedSessionKey && sessionTarget !== "isolated") {
|
||||
(job as { sessionKey?: string }).sessionKey = resolvedSessionKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(opts?.agentSessionKey || opts?.currentDeliveryContext) &&
|
||||
job &&
|
||||
typeof job === "object" &&
|
||||
"payload" in job &&
|
||||
(job as { payload?: { kind?: string } }).payload?.kind === "agentTurn"
|
||||
) {
|
||||
const deliveryValue = (job as { delivery?: unknown }).delivery;
|
||||
const delivery = isRecord(deliveryValue) ? deliveryValue : undefined;
|
||||
const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : "";
|
||||
const mode = normalizeLowercaseStringOrEmpty(modeRaw);
|
||||
if (mode === "webhook") {
|
||||
const webhookUrl = normalizeHttpWebhookUrl(delivery?.to);
|
||||
if (!webhookUrl) {
|
||||
throw new Error(
|
||||
'delivery.mode="webhook" requires delivery.to to be a valid http(s) URL',
|
||||
);
|
||||
}
|
||||
if (delivery) {
|
||||
delivery.to = webhookUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const hasTarget =
|
||||
(typeof delivery?.channel === "string" && delivery.channel.trim()) ||
|
||||
(typeof delivery?.to === "string" && delivery.to.trim());
|
||||
const shouldInfer =
|
||||
(deliveryValue == null || delivery) &&
|
||||
(mode === "" || mode === "announce") &&
|
||||
!hasTarget;
|
||||
if (shouldInfer) {
|
||||
const inferred = resolveCronCreationDelivery({
|
||||
cfg: runtimeConfig,
|
||||
currentDeliveryContext: opts.currentDeliveryContext,
|
||||
agentSessionKey: opts.agentSessionKey,
|
||||
});
|
||||
if (inferred) {
|
||||
(job as { delivery?: unknown }).delivery = {
|
||||
...inferred,
|
||||
...delivery,
|
||||
} satisfies CronDelivery;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contextMessages = readNonNegativeIntegerParam(params, "contextMessages") ?? 0;
|
||||
if (
|
||||
job &&
|
||||
typeof job === "object" &&
|
||||
"payload" in job &&
|
||||
(job as { payload?: { kind?: string; text?: string } }).payload?.kind ===
|
||||
"systemEvent"
|
||||
) {
|
||||
const payload = (job as { payload: { kind: string; text: string } }).payload;
|
||||
if (typeof payload.text === "string" && payload.text.trim()) {
|
||||
const contextLines = await buildReminderContextLines({
|
||||
agentSessionKey: opts?.agentSessionKey,
|
||||
gatewayOpts,
|
||||
contextMessages,
|
||||
callGatewayTool: callGateway,
|
||||
});
|
||||
if (contextLines.length > 0) {
|
||||
const baseText = stripExistingContext(payload.text);
|
||||
payload.text = `${baseText}${REMINDER_CONTEXT_MARKER}${contextLines.join("\n")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return jsonResult(
|
||||
await callGateway("cron.add", gatewayOpts, {
|
||||
...job,
|
||||
}),
|
||||
);
|
||||
if (!params.job || typeof params.job !== "object") {
|
||||
throw new Error("job required");
|
||||
}
|
||||
case "update": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
|
||||
// Flat-params recovery for patch
|
||||
let recoveredFlatPatch = false;
|
||||
if (isMissingOrEmptyObject(params.patch)) {
|
||||
const synthetic = recoverCronObjectFromFlatParams(params);
|
||||
if (synthetic.found) {
|
||||
params.patch = synthetic.value;
|
||||
recoveredFlatPatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.patch || typeof params.patch !== "object") {
|
||||
throw new Error("patch required");
|
||||
}
|
||||
const canonicalPatch = canonicalizeCronToolObject(
|
||||
params.patch as Record<string, unknown>,
|
||||
);
|
||||
assertNoCronCommandPayload(canonicalPatch);
|
||||
assertCronDeliveryInputNonBlankFields(canonicalPatch.delivery);
|
||||
const patch = normalizeCronJobPatch(canonicalPatch) ?? canonicalPatch;
|
||||
if (recoveredFlatPatch && isEmptyRecoveredCronPatch(patch)) {
|
||||
throw new Error("patch required");
|
||||
}
|
||||
if (callerScope && "agentId" in patch) {
|
||||
throw new Error("cron patch agentId cannot be changed by the agent cron tool");
|
||||
}
|
||||
if (callerScope) {
|
||||
assertCronToolSessionRefsMatchScope(patch, callerScope);
|
||||
}
|
||||
await capCronAgentTurnUpdatePatchToolsAllow({
|
||||
id,
|
||||
patch,
|
||||
creatorToolAllowlist: opts?.creatorToolAllowlist,
|
||||
gatewayOpts,
|
||||
callGateway,
|
||||
});
|
||||
return jsonResult(
|
||||
await callGateway("cron.update", gatewayOpts, {
|
||||
id,
|
||||
patch,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "remove": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
return jsonResult(
|
||||
await callGateway("cron.remove", gatewayOpts, {
|
||||
id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "run": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
const runMode =
|
||||
params.runMode === "due" || params.runMode === "force" ? params.runMode : "due";
|
||||
return jsonResult(
|
||||
await callGateway("cron.run", gatewayOpts, {
|
||||
id,
|
||||
mode: runMode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "runs": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
return jsonResult(
|
||||
await callGateway("cron.runs", gatewayOpts, {
|
||||
id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "wake": {
|
||||
const text = readStringParam(params, "text", { required: true });
|
||||
const mode =
|
||||
params.mode === "now" || params.mode === "next-heartbeat"
|
||||
? params.mode
|
||||
: "next-heartbeat";
|
||||
// Resolve the calling agent's session key into the internal form
|
||||
// the cron service routes by (mirrors the `add` action above).
|
||||
// Without this, the wake gateway call goes through with no session
|
||||
// key and the system event lands on the heartbeat / main default
|
||||
// rather than the originating conversation lane. Closes the
|
||||
// upstream half of openclaw/openclaw#46886 (#64556 — agentId/
|
||||
// sessionKey silently ignored for `action: "wake"`). Explicit
|
||||
// params on the tool call still take precedence over the inferred
|
||||
// value, so call sites that want to wake a different session can
|
||||
// pass `sessionKey` / `agentId` directly.
|
||||
const cfg = getRuntimeConfig();
|
||||
const canonicalJob = canonicalizeCronToolObject(params.job as Record<string, unknown>);
|
||||
assertNoCronCommandPayload(canonicalJob);
|
||||
assertCronDeliveryInputNonBlankFields(canonicalJob.delivery);
|
||||
const job =
|
||||
normalizeCronJobCreate(canonicalJob, {
|
||||
sessionContext: { sessionKey: opts?.agentSessionKey },
|
||||
}) ?? canonicalJob;
|
||||
capCronAgentTurnJobToolsAllow(job, opts?.creatorToolAllowlist);
|
||||
const cfg = getRuntimeConfig();
|
||||
if (job && typeof job === "object") {
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
const explicitSessionKey = readStringParam(params, "sessionKey");
|
||||
const explicitAgentId = readStringParam(params, "agentId");
|
||||
const inferredSessionKey = opts?.agentSessionKey
|
||||
const resolvedSessionKey = opts?.agentSessionKey
|
||||
? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey })
|
||||
: undefined;
|
||||
const inferredAgentId = opts?.agentSessionKey
|
||||
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
|
||||
: undefined;
|
||||
const sessionKey = explicitSessionKey ?? inferredSessionKey;
|
||||
// When a caller supplies an explicit cross-agent sessionKey without
|
||||
// an explicit agentId, the gateway target resolver treats agentId as
|
||||
// authoritative — pairing the caller's inferred agentId with a
|
||||
// foreign session key would canonicalize the wake back to the
|
||||
// caller's main lane. Derive the agentId from the explicit canonical
|
||||
// session key instead; only fall through to the inferred
|
||||
// caller-agent when no explicit sessionKey was supplied.
|
||||
const agentIdFromExplicitSessionKey = explicitSessionKey
|
||||
? parseAgentSessionKey(explicitSessionKey)?.agentId
|
||||
: undefined;
|
||||
// A contradictory explicit pair (agentId X + a sessionKey owned by
|
||||
// agent Y) is ambiguous: the gateway target resolver treats agentId
|
||||
// as authoritative and would silently canonicalize the wake onto a
|
||||
// session under X that the caller never named. Reject instead of
|
||||
// guessing one canonical owner.
|
||||
if (
|
||||
explicitAgentId &&
|
||||
agentIdFromExplicitSessionKey &&
|
||||
normalizeLowercaseStringOrEmpty(explicitAgentId) !==
|
||||
normalizeLowercaseStringOrEmpty(agentIdFromExplicitSessionKey)
|
||||
) {
|
||||
throw new Error(
|
||||
`wake agentId "${explicitAgentId}" contradicts the agent that owns sessionKey ` +
|
||||
`("${agentIdFromExplicitSessionKey}"); pass a single canonical wake target`,
|
||||
);
|
||||
if (!("agentId" in job) || (job as { agentId?: unknown }).agentId === undefined) {
|
||||
const agentId = opts?.agentSessionKey
|
||||
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
|
||||
: undefined;
|
||||
if (agentId) {
|
||||
(job as { agentId?: string }).agentId = agentId;
|
||||
}
|
||||
}
|
||||
const agentId =
|
||||
explicitAgentId ??
|
||||
(explicitSessionKey ? agentIdFromExplicitSessionKey : inferredAgentId);
|
||||
return jsonResult(
|
||||
await callGateway(
|
||||
"wake",
|
||||
const sessionTarget = normalizeLowercaseStringOrEmpty(
|
||||
(job as { sessionTarget?: unknown }).sessionTarget,
|
||||
);
|
||||
if (!("sessionKey" in job) && resolvedSessionKey && sessionTarget !== "isolated") {
|
||||
(job as { sessionKey?: string }).sessionKey = resolvedSessionKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(opts?.agentSessionKey || opts?.currentDeliveryContext) &&
|
||||
job &&
|
||||
typeof job === "object" &&
|
||||
"payload" in job &&
|
||||
(job as { payload?: { kind?: string } }).payload?.kind === "agentTurn"
|
||||
) {
|
||||
const deliveryValue = (job as { delivery?: unknown }).delivery;
|
||||
const delivery = isRecord(deliveryValue) ? deliveryValue : undefined;
|
||||
const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : "";
|
||||
const mode = normalizeLowercaseStringOrEmpty(modeRaw);
|
||||
if (mode === "webhook") {
|
||||
const webhookUrl = normalizeHttpWebhookUrl(delivery?.to);
|
||||
if (!webhookUrl) {
|
||||
throw new Error(
|
||||
'delivery.mode="webhook" requires delivery.to to be a valid http(s) URL',
|
||||
);
|
||||
}
|
||||
if (delivery) {
|
||||
delivery.to = webhookUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const hasTarget =
|
||||
(typeof delivery?.channel === "string" && delivery.channel.trim()) ||
|
||||
(typeof delivery?.to === "string" && delivery.to.trim());
|
||||
const shouldInfer =
|
||||
(deliveryValue == null || delivery) &&
|
||||
(mode === "" || mode === "announce") &&
|
||||
!hasTarget;
|
||||
if (shouldInfer) {
|
||||
const inferred = resolveCronCreationDelivery({
|
||||
cfg,
|
||||
currentDeliveryContext: opts.currentDeliveryContext,
|
||||
agentSessionKey: opts.agentSessionKey,
|
||||
});
|
||||
if (inferred) {
|
||||
(job as { delivery?: unknown }).delivery = {
|
||||
...inferred,
|
||||
...delivery,
|
||||
} satisfies CronDelivery;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contextMessages = readNonNegativeIntegerParam(params, "contextMessages") ?? 0;
|
||||
if (
|
||||
job &&
|
||||
typeof job === "object" &&
|
||||
"payload" in job &&
|
||||
(job as { payload?: { kind?: string; text?: string } }).payload?.kind === "systemEvent"
|
||||
) {
|
||||
const payload = (job as { payload: { kind: string; text: string } }).payload;
|
||||
if (typeof payload.text === "string" && payload.text.trim()) {
|
||||
const contextLines = await buildReminderContextLines({
|
||||
agentSessionKey: opts?.agentSessionKey,
|
||||
gatewayOpts,
|
||||
{
|
||||
mode,
|
||||
text,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(agentId ? { agentId } : {}),
|
||||
},
|
||||
{ expectFinal: false },
|
||||
),
|
||||
contextMessages,
|
||||
callGatewayTool: callGateway,
|
||||
});
|
||||
if (contextLines.length > 0) {
|
||||
const baseText = stripExistingContext(payload.text);
|
||||
payload.text = `${baseText}${REMINDER_CONTEXT_MARKER}${contextLines.join("\n")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return jsonResult(await callGateway("cron.add", gatewayOpts, job));
|
||||
}
|
||||
case "update": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
|
||||
// Flat-params recovery for patch
|
||||
let recoveredFlatPatch = false;
|
||||
if (isMissingOrEmptyObject(params.patch)) {
|
||||
const synthetic = recoverCronObjectFromFlatParams(params);
|
||||
if (synthetic.found) {
|
||||
params.patch = synthetic.value;
|
||||
recoveredFlatPatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.patch || typeof params.patch !== "object") {
|
||||
throw new Error("patch required");
|
||||
}
|
||||
const canonicalPatch = canonicalizeCronToolObject(
|
||||
params.patch as Record<string, unknown>,
|
||||
);
|
||||
assertNoCronCommandPayload(canonicalPatch);
|
||||
assertCronDeliveryInputNonBlankFields(canonicalPatch.delivery);
|
||||
const patch = normalizeCronJobPatch(canonicalPatch) ?? canonicalPatch;
|
||||
if (recoveredFlatPatch && isEmptyRecoveredCronPatch(patch)) {
|
||||
throw new Error("patch required");
|
||||
}
|
||||
await capCronAgentTurnUpdatePatchToolsAllow({
|
||||
id,
|
||||
patch,
|
||||
creatorToolAllowlist: opts?.creatorToolAllowlist,
|
||||
gatewayOpts,
|
||||
callGateway,
|
||||
});
|
||||
return jsonResult(
|
||||
await callGateway("cron.update", gatewayOpts, {
|
||||
id,
|
||||
patch,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "remove": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
return jsonResult(await callGateway("cron.remove", gatewayOpts, { id }));
|
||||
}
|
||||
case "run": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
const runMode =
|
||||
params.runMode === "due" || params.runMode === "force" ? params.runMode : "due";
|
||||
return jsonResult(await callGateway("cron.run", gatewayOpts, { id, mode: runMode }));
|
||||
}
|
||||
case "runs": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
return jsonResult(await callGateway("cron.runs", gatewayOpts, { id }));
|
||||
}
|
||||
case "wake": {
|
||||
const text = readStringParam(params, "text", { required: true });
|
||||
const mode =
|
||||
params.mode === "now" || params.mode === "next-heartbeat"
|
||||
? params.mode
|
||||
: "next-heartbeat";
|
||||
// Resolve the calling agent's session key into the internal form
|
||||
// the cron service routes by (mirrors the `add` action above).
|
||||
// Without this, the wake gateway call goes through with no session
|
||||
// key and the system event lands on the heartbeat / main default
|
||||
// rather than the originating conversation lane. Closes the
|
||||
// upstream half of openclaw/openclaw#46886 (#64556 — agentId/
|
||||
// sessionKey silently ignored for `action: "wake"`). Explicit
|
||||
// params on the tool call still take precedence over the inferred
|
||||
// value, so call sites that want to wake a different session can
|
||||
// pass `sessionKey` / `agentId` directly.
|
||||
const cfg = getRuntimeConfig();
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
const explicitSessionKey = readStringParam(params, "sessionKey");
|
||||
const explicitAgentId = readStringParam(params, "agentId");
|
||||
const inferredSessionKey = opts?.agentSessionKey
|
||||
? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey })
|
||||
: undefined;
|
||||
const inferredAgentId = opts?.agentSessionKey
|
||||
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
|
||||
: undefined;
|
||||
const sessionKey = explicitSessionKey ?? inferredSessionKey;
|
||||
// When a caller supplies an explicit cross-agent sessionKey without
|
||||
// an explicit agentId, the gateway target resolver treats agentId as
|
||||
// authoritative — pairing the caller's inferred agentId with a
|
||||
// foreign session key would canonicalize the wake back to the
|
||||
// caller's main lane. Derive the agentId from the explicit canonical
|
||||
// session key instead; only fall through to the inferred
|
||||
// caller-agent when no explicit sessionKey was supplied.
|
||||
const agentIdFromExplicitSessionKey = explicitSessionKey
|
||||
? parseAgentSessionKey(explicitSessionKey)?.agentId
|
||||
: undefined;
|
||||
// A contradictory explicit pair (agentId X + a sessionKey owned by
|
||||
// agent Y) is ambiguous: the gateway target resolver treats agentId
|
||||
// as authoritative and would silently canonicalize the wake onto a
|
||||
// session under X that the caller never named. Reject instead of
|
||||
// guessing one canonical owner.
|
||||
if (
|
||||
explicitAgentId &&
|
||||
agentIdFromExplicitSessionKey &&
|
||||
normalizeLowercaseStringOrEmpty(explicitAgentId) !==
|
||||
normalizeLowercaseStringOrEmpty(agentIdFromExplicitSessionKey)
|
||||
) {
|
||||
throw new Error(
|
||||
`wake agentId "${explicitAgentId}" contradicts the agent that owns sessionKey ` +
|
||||
`("${agentIdFromExplicitSessionKey}"); pass a single canonical wake target`,
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
const agentId =
|
||||
explicitAgentId ??
|
||||
(explicitSessionKey ? agentIdFromExplicitSessionKey : inferredAgentId);
|
||||
return jsonResult(
|
||||
await callGateway(
|
||||
"wake",
|
||||
gatewayOpts,
|
||||
{
|
||||
mode,
|
||||
text,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(agentId ? { agentId } : {}),
|
||||
},
|
||||
{ expectFinal: false },
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
return setToolTerminalPresentation(tool, formatCronTerminalPresentation);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getPluginToolMeta, setPluginToolMeta } from "../../plugins/tools.js";
|
||||
import {
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "../agent-tools.before-tool-call.js";
|
||||
import { getChannelAgentToolMeta, setChannelAgentToolMeta } from "../channel-tool-metadata.js";
|
||||
import {
|
||||
getToolTerminalPresentation,
|
||||
setToolTerminalPresentation,
|
||||
} from "../tool-terminal-presentation.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { wrapToolWithGatewayCallerIdentity } from "./gateway-caller-context.js";
|
||||
|
||||
describe("gateway caller context wrapper", () => {
|
||||
it("preserves tool metadata used by policy and presentation layers", () => {
|
||||
const tool: AnyAgentTool = {
|
||||
name: "plugin_tool",
|
||||
label: "Plugin tool",
|
||||
description: "plugin tool",
|
||||
parameters: Type.Object({}),
|
||||
execute: vi.fn(async () => ({
|
||||
content: [{ type: "text" as const, text: "ok" }],
|
||||
details: {},
|
||||
})),
|
||||
};
|
||||
setPluginToolMeta(tool, { pluginId: "plugin-a", optional: false });
|
||||
setChannelAgentToolMeta(tool as never, { channelId: "telegram" });
|
||||
setToolTerminalPresentation(tool, () => ({ text: "done" }));
|
||||
|
||||
const beforeWrapped = wrapToolWithBeforeToolCallHook(tool);
|
||||
const wrapped = wrapToolWithGatewayCallerIdentity(beforeWrapped, {
|
||||
agentId: "agent-a",
|
||||
sessionKey: "agent-a:session",
|
||||
});
|
||||
|
||||
expect(getPluginToolMeta(wrapped)).toEqual({ pluginId: "plugin-a", optional: false });
|
||||
expect(getChannelAgentToolMeta(wrapped as never)).toEqual({ channelId: "telegram" });
|
||||
expect(getToolTerminalPresentation(wrapped)).toBe(getToolTerminalPresentation(tool));
|
||||
expect(isToolWrappedWithBeforeToolCallHook(wrapped)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
// Ambient trusted caller context for model-mediated Gateway tool calls.
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { copyPluginToolMeta } from "../../plugins/tools.js";
|
||||
import { copyBeforeToolCallHookMarker } from "../before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "../channel-tools.js";
|
||||
import { copyToolTerminalPresentation } from "../tool-terminal-presentation.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
|
||||
export type GatewayToolCallerIdentity = {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
const gatewayToolCallerStorage = new AsyncLocalStorage<GatewayToolCallerIdentity>();
|
||||
|
||||
export function getGatewayToolCallerIdentity(): GatewayToolCallerIdentity | undefined {
|
||||
return gatewayToolCallerStorage.getStore();
|
||||
}
|
||||
|
||||
export async function withGatewayToolCallerIdentity<T>(
|
||||
identity: GatewayToolCallerIdentity | undefined,
|
||||
run: () => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
if (!identity?.agentId?.trim() || !identity.sessionKey?.trim()) {
|
||||
return await run();
|
||||
}
|
||||
return await gatewayToolCallerStorage.run(
|
||||
{
|
||||
agentId: identity.agentId.trim(),
|
||||
sessionKey: identity.sessionKey.trim(),
|
||||
},
|
||||
run,
|
||||
);
|
||||
}
|
||||
|
||||
export function wrapToolWithGatewayCallerIdentity(
|
||||
tool: AnyAgentTool,
|
||||
identity: GatewayToolCallerIdentity | undefined,
|
||||
): AnyAgentTool {
|
||||
if (!identity?.agentId?.trim() || !identity.sessionKey?.trim() || !tool.execute) {
|
||||
return tool;
|
||||
}
|
||||
const wrapped: AnyAgentTool = {
|
||||
...tool,
|
||||
execute: async (...args) =>
|
||||
await withGatewayToolCallerIdentity(identity, async () => await tool.execute?.(...args)),
|
||||
};
|
||||
copyPluginToolMeta(tool, wrapped);
|
||||
copyChannelAgentToolMeta(tool as never, wrapped as never);
|
||||
copyBeforeToolCallHookMarker(tool, wrapped);
|
||||
copyToolTerminalPresentation(tool, wrapped);
|
||||
return wrapped;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CallGatewayOptions } from "../../gateway/call.js";
|
||||
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js";
|
||||
import { callGatewayTool, readGatewayCallOptions, resolveGatewayOptions } from "./gateway.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -319,83 +318,6 @@ describe("gateway tool defaults", () => {
|
||||
expect(call.deviceIdentity).toEqual(mocks.deviceIdentity);
|
||||
});
|
||||
|
||||
it("does not mark direct cron helper calls with agent runtime identity", async () => {
|
||||
mocks.callGateway.mockResolvedValueOnce({ id: "job-1" });
|
||||
|
||||
await callGatewayTool("cron.remove", {}, { id: "job-1" });
|
||||
|
||||
const call = capturedGatewayCall();
|
||||
expect(call.method).toBe("cron.remove");
|
||||
expect(call.params).toEqual({ id: "job-1" });
|
||||
expect(call).not.toHaveProperty("agentRuntimeIdentityToken");
|
||||
});
|
||||
|
||||
it("marks local cron calls from trusted tool context with agent runtime identity", async () => {
|
||||
mocks.callGateway.mockResolvedValueOnce({ id: "job-1" });
|
||||
|
||||
await withGatewayToolCallerIdentity(
|
||||
{ agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" },
|
||||
async () => {
|
||||
await callGatewayTool("cron.remove", {}, { id: "job-1" });
|
||||
},
|
||||
);
|
||||
|
||||
const call = capturedGatewayCall();
|
||||
expect(call.method).toBe("cron.remove");
|
||||
expect(call.params).toEqual({ id: "job-1" });
|
||||
expect(call.agentRuntimeIdentityToken).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("fails contextual cron calls closed for gatewayUrl overrides", async () => {
|
||||
await expect(
|
||||
withGatewayToolCallerIdentity(
|
||||
{ agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" },
|
||||
async () => {
|
||||
await callGatewayTool(
|
||||
"cron.remove",
|
||||
{ gatewayUrl: "ws://127.0.0.1:18789" },
|
||||
{ id: "job-1" },
|
||||
);
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("agent cron gateway calls require the trusted local gateway context");
|
||||
expect(mocks.callGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails contextual cron calls closed for explicit gateway tokens", async () => {
|
||||
await expect(
|
||||
withGatewayToolCallerIdentity(
|
||||
{ agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" },
|
||||
async () => {
|
||||
await callGatewayTool("cron.remove", { gatewayToken: "token" }, { id: "job-1" });
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("agent cron gateway calls require the trusted local gateway context");
|
||||
expect(mocks.callGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails contextual cron calls closed for configured remote gateways", async () => {
|
||||
mocks.configState.value = {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
url: "wss://gateway.example",
|
||||
token: "remote-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
withGatewayToolCallerIdentity(
|
||||
{ agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" },
|
||||
async () => {
|
||||
await callGatewayTool("cron.remove", {}, { id: "job-1" });
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("agent cron gateway calls require the trusted local gateway context");
|
||||
expect(mocks.callGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks local approval wait calls as approval runtime calls", async () => {
|
||||
mocks.callGateway.mockResolvedValueOnce({ decision: "allow-once" });
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "../../../packages/gateway-protocol/src/client-info.js";
|
||||
import { getRuntimeConfig, resolveGatewayPort } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { mintAgentRuntimeIdentityToken } from "../../gateway/agent-runtime-identity-token.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { resolveGatewayCredentialsFromConfig, trimToUndefined } from "../../gateway/credentials.js";
|
||||
import {
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
} from "../../infra/device-identity.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { readPositiveIntegerParam, readStringParam } from "./common.js";
|
||||
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
|
||||
|
||||
/** Optional gateway connection overrides accepted by agent tools. */
|
||||
export type GatewayCallOptions = {
|
||||
@@ -210,16 +208,6 @@ const APPROVAL_RUNTIME_METHODS = new Set<string>([
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
|
||||
const AGENT_RUNTIME_IDENTITY_METHODS = new Set<string>([
|
||||
"cron.list",
|
||||
"cron.get",
|
||||
"cron.add",
|
||||
"cron.update",
|
||||
"cron.remove",
|
||||
"cron.run",
|
||||
"cron.runs",
|
||||
]);
|
||||
|
||||
function resolveApprovalRuntimeTokenForGatewayTool(params: {
|
||||
method: string;
|
||||
opts: GatewayCallOptions;
|
||||
@@ -275,26 +263,6 @@ function resolveApprovalRequesterDeviceIdentityForGatewayTool(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentRuntimeIdentityTokenForGatewayTool(params: {
|
||||
method: string;
|
||||
opts: GatewayCallOptions;
|
||||
target: GatewayOverrideTarget;
|
||||
}): string | undefined {
|
||||
if (!AGENT_RUNTIME_IDENTITY_METHODS.has(params.method)) {
|
||||
return undefined;
|
||||
}
|
||||
const identity = getGatewayToolCallerIdentity();
|
||||
if (!identity) {
|
||||
return undefined;
|
||||
}
|
||||
const hasGatewayUrlOverride = trimToUndefined(params.opts.gatewayUrl) !== undefined;
|
||||
const hasGatewayTokenOverride = trimToUndefined(params.opts.gatewayToken) !== undefined;
|
||||
if (hasGatewayUrlOverride || hasGatewayTokenOverride || params.target !== "local") {
|
||||
throw new Error("agent cron gateway calls require the trusted local gateway context");
|
||||
}
|
||||
return mintAgentRuntimeIdentityToken(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a gateway method as the agent-tool backend client with least-privilege scopes.
|
||||
*/
|
||||
@@ -313,11 +281,6 @@ export async function callGatewayTool<T = Record<string, unknown>>(
|
||||
opts,
|
||||
target: gateway.target,
|
||||
});
|
||||
const agentRuntimeIdentityToken = resolveAgentRuntimeIdentityTokenForGatewayTool({
|
||||
method,
|
||||
opts,
|
||||
target: gateway.target,
|
||||
});
|
||||
const deviceIdentity = resolveApprovalRequesterDeviceIdentityForGatewayTool({
|
||||
method,
|
||||
opts,
|
||||
@@ -334,7 +297,6 @@ export async function callGatewayTool<T = Record<string, unknown>>(
|
||||
clientDisplayName: "agent",
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
...(approvalRuntimeToken ? { approvalRuntimeToken } : {}),
|
||||
...(agentRuntimeIdentityToken ? { agentRuntimeIdentityToken } : {}),
|
||||
...(deviceIdentity ? { deviceIdentity } : {}),
|
||||
scopes,
|
||||
});
|
||||
|
||||
@@ -957,8 +957,6 @@ describe("buildStatusReply subagent summary", () => {
|
||||
OPENAI_API_KEY: undefined,
|
||||
OPENAI_OAUTH_TOKEN: undefined,
|
||||
},
|
||||
skipSessionCleanup: true,
|
||||
skipHomeCleanup: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1044,8 +1042,6 @@ describe("buildStatusReply subagent summary", () => {
|
||||
OPENAI_API_KEY: undefined,
|
||||
OPENAI_OAUTH_TOKEN: undefined,
|
||||
},
|
||||
skipSessionCleanup: true,
|
||||
skipHomeCleanup: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1070,69 +1066,66 @@ describe("buildStatusReply subagent summary", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await withTempHome(
|
||||
async (dir) => {
|
||||
saveStatusTestAuthProfile({ dir, profileId: "work", provider: "openai" });
|
||||
await withTempHome(async (dir) => {
|
||||
saveStatusTestAuthProfile({ dir, profileId: "work", provider: "openai" });
|
||||
|
||||
const text = await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
const text = await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-synthetic-usage",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "work",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 32_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "oauth",
|
||||
activeModelAuthOverride: "oauth",
|
||||
});
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-synthetic-usage",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "work",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 32_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "oauth",
|
||||
activeModelAuthOverride: "oauth",
|
||||
});
|
||||
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Model: openai/gpt-5.5");
|
||||
expect(normalized).toContain("Runtime: OpenAI Codex");
|
||||
expect(normalized).toContain("Usage: 5h 91% left");
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
if (!providerUsageCall) {
|
||||
throw new Error("expected provider usage summary call for synthetic Codex auth");
|
||||
}
|
||||
expect(providerUsageCall[0]).toMatchObject({
|
||||
timeoutMs: 8000,
|
||||
providers: ["openai"],
|
||||
auth: [
|
||||
{
|
||||
...expectedCodexRuntimeUsageAuth[0],
|
||||
authProfileId: "work",
|
||||
},
|
||||
],
|
||||
config: expect.objectContaining({
|
||||
agents: expect.objectContaining({
|
||||
defaults: expect.objectContaining({ agentRuntime: { id: "codex" } }),
|
||||
}),
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Model: openai/gpt-5.5");
|
||||
expect(normalized).toContain("Runtime: OpenAI Codex");
|
||||
expect(normalized).toContain("Usage: 5h 91% left");
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
if (!providerUsageCall) {
|
||||
throw new Error("expected provider usage summary call for synthetic Codex auth");
|
||||
}
|
||||
expect(providerUsageCall[0]).toMatchObject({
|
||||
timeoutMs: 8000,
|
||||
providers: ["openai"],
|
||||
auth: [
|
||||
{
|
||||
...expectedCodexRuntimeUsageAuth[0],
|
||||
authProfileId: "work",
|
||||
},
|
||||
],
|
||||
config: expect.objectContaining({
|
||||
agents: expect.objectContaining({
|
||||
defaults: expect.objectContaining({ agentRuntime: { id: "codex" } }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ skipSessionCleanup: true, skipHomeCleanup: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards legacy Codex profile providers to Codex synthetic usage", async () => {
|
||||
@@ -1148,74 +1141,14 @@ describe("buildStatusReply subagent summary", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await withTempHome(
|
||||
async (dir) => {
|
||||
saveStatusTestAuthProfile({
|
||||
dir,
|
||||
profileId: "openai-codex:legacy",
|
||||
provider: "openai-codex",
|
||||
});
|
||||
await withTempHome(async (dir) => {
|
||||
saveStatusTestAuthProfile({
|
||||
dir,
|
||||
profileId: "openai-codex:legacy",
|
||||
provider: "openai-codex",
|
||||
});
|
||||
|
||||
await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-legacy-profile",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "openai-codex:legacy",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 32_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "oauth",
|
||||
activeModelAuthOverride: "oauth",
|
||||
});
|
||||
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
expect(providerUsageCall?.[0]?.auth).toEqual([
|
||||
{
|
||||
...expectedCodexRuntimeUsageAuth[0],
|
||||
authProfileId: "openai-codex:legacy",
|
||||
},
|
||||
]);
|
||||
},
|
||||
{ skipSessionCleanup: true, skipHomeCleanup: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("loads Codex synthetic usage when no local OpenAI profile label exists", async () => {
|
||||
registerStatusCodexHarness();
|
||||
providerUsageMock.loadProviderUsageSummary.mockResolvedValue({
|
||||
updatedAt: Date.now(),
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 16 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await withTempHome(async () => {
|
||||
const text = await buildStatusText({
|
||||
await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
@@ -1225,8 +1158,9 @@ describe("buildStatusReply subagent summary", () => {
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-no-profile",
|
||||
sessionId: "sess-status-codex-legacy-profile",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "openai-codex:legacy",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
@@ -1241,13 +1175,19 @@ describe("buildStatusReply subagent summary", () => {
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "oauth",
|
||||
activeModelAuthOverride: "oauth",
|
||||
});
|
||||
|
||||
expect(normalizeTestText(text)).toContain("Usage: 5h 84% left");
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth);
|
||||
expect(providerUsageCall?.[0]?.auth).toEqual([
|
||||
{
|
||||
...expectedCodexRuntimeUsageAuth[0],
|
||||
authProfileId: "openai-codex:legacy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1264,52 +1204,49 @@ describe("buildStatusReply subagent summary", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await withTempHome(
|
||||
async (dir) => {
|
||||
saveStatusTestAuthProfiles({
|
||||
dir,
|
||||
profiles: [
|
||||
{ profileId: "openai:status", provider: "openai" },
|
||||
{ profileId: "anthropic:work", provider: "anthropic" },
|
||||
],
|
||||
});
|
||||
await withTempHome(async (dir) => {
|
||||
saveStatusTestAuthProfiles({
|
||||
dir,
|
||||
profiles: [
|
||||
{ profileId: "openai:status", provider: "openai" },
|
||||
{ profileId: "anthropic:work", provider: "anthropic" },
|
||||
],
|
||||
});
|
||||
|
||||
await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-stale-profile",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "anthropic:work",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 32_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
});
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-stale-profile",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "anthropic:work",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 32_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
});
|
||||
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth);
|
||||
},
|
||||
{ skipSessionCleanup: true, skipHomeCleanup: true },
|
||||
);
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses active fallback provider usage for legacy fallback notices", async () => {
|
||||
@@ -1814,7 +1751,7 @@ describe("buildStatusReply subagent summary", () => {
|
||||
expect(normalized).toContain("oauth (openai:status)");
|
||||
expect(normalized).not.toContain("api-key (openai:backup)");
|
||||
},
|
||||
{ env: { OPENAI_API_KEY: undefined }, skipSessionCleanup: true, skipHomeCleanup: true },
|
||||
{ env: { OPENAI_API_KEY: undefined } },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({
|
||||
getDaemonStatusSummary: vi.fn(),
|
||||
getNodeDaemonStatusSummary: vi.fn(),
|
||||
resolveReadOnlyChannelPluginsForConfig: vi.fn(),
|
||||
resolveModelAuthLabel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/read-only.js", () => ({
|
||||
@@ -29,10 +28,6 @@ vi.mock("../infra/provider-usage.js", () => ({
|
||||
loadProviderUsageSummary: mocks.loadProviderUsageSummary,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/model-auth-label.js", () => ({
|
||||
resolveModelAuthLabel: mocks.resolveModelAuthLabel,
|
||||
}));
|
||||
|
||||
vi.mock("../security/audit.runtime.js", () => ({
|
||||
runSecurityAudit: mocks.runSecurityAudit,
|
||||
}));
|
||||
@@ -50,8 +45,6 @@ function requireProviderUsageCall(): {
|
||||
timeoutMs?: number;
|
||||
config?: unknown;
|
||||
agentDir?: string;
|
||||
providers?: string[];
|
||||
auth?: Array<Record<string, unknown>>;
|
||||
} {
|
||||
const call = mocks.loadProviderUsageSummary.mock.calls[0];
|
||||
if (!call) {
|
||||
@@ -65,8 +58,6 @@ function requireProviderUsageCall(): {
|
||||
timeoutMs?: number;
|
||||
config?: unknown;
|
||||
agentDir?: string;
|
||||
providers?: string[];
|
||||
auth?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,7 +69,6 @@ describe("status-runtime-shared", () => {
|
||||
mocks.callGateway.mockResolvedValue({ ok: true });
|
||||
mocks.getDaemonStatusSummary.mockResolvedValue({ label: "LaunchAgent" });
|
||||
mocks.getNodeDaemonStatusSummary.mockResolvedValue({ label: "node" });
|
||||
mocks.resolveModelAuthLabel.mockReturnValue(undefined);
|
||||
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
|
||||
plugins: [{ id: "telegram" }],
|
||||
configuredChannelIds: ["telegram"],
|
||||
@@ -144,176 +134,6 @@ describe("status-runtime-shared", () => {
|
||||
expect(usageCall.agentDir).toContain("main");
|
||||
});
|
||||
|
||||
it("adds Codex synthetic usage for configured OpenAI Codex runtime routes without profiles", async () => {
|
||||
mocks.loadProviderUsageSummary
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: 1,
|
||||
providers: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
displayName: "Claude",
|
||||
windows: [],
|
||||
error: "HTTP 429",
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: 2,
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 9 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveStatusUsageSummary({
|
||||
timeoutMs: 3456,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir: "/tmp/status-agent",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
updatedAt: 1,
|
||||
providers: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
displayName: "Claude",
|
||||
windows: [],
|
||||
error: "HTTP 429",
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 9 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.loadProviderUsageSummary).toHaveBeenNthCalledWith(2, {
|
||||
timeoutMs: 3456,
|
||||
providers: ["openai"],
|
||||
auth: [
|
||||
{
|
||||
provider: "openai",
|
||||
token: "codex-app-server",
|
||||
hookProvider: "codex",
|
||||
},
|
||||
],
|
||||
config: expect.any(Object),
|
||||
agentDir: "/tmp/status-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps existing OpenAI usage when Codex synthetic usage has no windows", async () => {
|
||||
mocks.loadProviderUsageSummary
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: 1,
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 22 }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: 2,
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveStatusUsageSummary({
|
||||
timeoutMs: 3456,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir: "/tmp/status-agent",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
updatedAt: 1,
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 22 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add Codex synthetic usage for OpenAI routes pinned to OpenClaw runtime", async () => {
|
||||
await resolveStatusUsageSummary({
|
||||
timeoutMs: 3456,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { agentRuntime: { id: "openclaw" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir: "/tmp/status-agent",
|
||||
});
|
||||
|
||||
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce();
|
||||
expect(requireProviderUsageCall()).not.toHaveProperty("auth");
|
||||
});
|
||||
|
||||
it("does not add Codex synthetic usage for API-key-backed OpenAI Codex runtime routes", async () => {
|
||||
mocks.resolveModelAuthLabel.mockReturnValue("api-key (openai:api)");
|
||||
|
||||
await resolveStatusUsageSummary({
|
||||
timeoutMs: 3456,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir: "/tmp/status-agent",
|
||||
});
|
||||
|
||||
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce();
|
||||
expect(requireProviderUsageCall()).not.toHaveProperty("auth");
|
||||
expect(mocks.resolveModelAuthLabel).toHaveBeenCalledWith({
|
||||
provider: "openai",
|
||||
acceptedProviderIds: ["openai"],
|
||||
cfg: expect.any(Object),
|
||||
agentDir: "/tmp/status-agent",
|
||||
includeExternalProfiles: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves usage summaries with explicit agent scope", async () => {
|
||||
await resolveStatusUsageSummary({
|
||||
timeoutMs: 2345,
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
// Shared runtime probes used by status text and JSON commands.
|
||||
// Heavy modules stay lazily loaded so fast status output avoids security/provider/gateway costs.
|
||||
|
||||
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
|
||||
import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
|
||||
import { resolveModelAuthLabel } from "../agents/model-auth-label.js";
|
||||
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../agents/openai-routing.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import {
|
||||
buildCodexSyntheticUsageAuth,
|
||||
mergeUsageSummaries,
|
||||
shouldUseCodexSyntheticUsageForRuntime,
|
||||
} from "../status/codex-synthetic-usage.js";
|
||||
import type { HealthSummary } from "./health.js";
|
||||
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
|
||||
|
||||
@@ -43,58 +33,6 @@ function loadGatewayCallModule() {
|
||||
return gatewayCallModuleLoader.load();
|
||||
}
|
||||
|
||||
function resolveUsageCredentialType(authLabel?: string): "oauth" | "token" | "api_key" | undefined {
|
||||
const auth = normalizeOptionalLowercaseString(authLabel);
|
||||
if (!auth) {
|
||||
return undefined;
|
||||
}
|
||||
if (auth.startsWith("oauth")) {
|
||||
return "oauth";
|
||||
}
|
||||
if (auth.startsWith("token")) {
|
||||
return "token";
|
||||
}
|
||||
if (auth.startsWith("api-key") || auth.startsWith("api key")) {
|
||||
return "api_key";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldUseConfiguredCodexSyntheticUsage(params: {
|
||||
config: OpenClawConfig;
|
||||
agentDir: string;
|
||||
}): boolean {
|
||||
const configuredDefault = resolveDefaultModelForAgent({
|
||||
cfg: params.config,
|
||||
allowPluginNormalization: false,
|
||||
});
|
||||
const policy = resolveAgentHarnessPolicy({
|
||||
config: params.config,
|
||||
provider: configuredDefault.provider,
|
||||
modelId: configuredDefault.model,
|
||||
});
|
||||
if (
|
||||
!shouldUseCodexSyntheticUsageForRuntime({
|
||||
provider: configuredDefault.provider,
|
||||
effectiveHarness: policy.runtime,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const authLabel = resolveModelAuthLabel({
|
||||
provider: configuredDefault.provider,
|
||||
acceptedProviderIds: listOpenAIAuthProfileProvidersForAgentRuntime({
|
||||
provider: configuredDefault.provider,
|
||||
harnessRuntime: policy.runtime,
|
||||
config: params.config,
|
||||
}),
|
||||
cfg: params.config,
|
||||
agentDir: params.agentDir,
|
||||
includeExternalProfiles: false,
|
||||
});
|
||||
return resolveUsageCredentialType(authLabel) !== "api_key";
|
||||
}
|
||||
|
||||
/** Runs the lightweight security audit used by status JSON/all output. */
|
||||
export async function resolveStatusSecurityAudit(params: {
|
||||
config: OpenClawConfig;
|
||||
@@ -131,23 +69,11 @@ type StatusUsageSummaryOptions = {
|
||||
/** Loads provider usage for status output, defaulting to the config's default agent directory. */
|
||||
export async function resolveStatusUsageSummary(params: StatusUsageSummaryOptions) {
|
||||
const { loadProviderUsageSummary } = await loadProviderUsage();
|
||||
const agentDir = params.agentDir ?? resolveDefaultAgentDir(params.config);
|
||||
const usage = await loadProviderUsageSummary({
|
||||
return await loadProviderUsageSummary({
|
||||
timeoutMs: params.timeoutMs,
|
||||
config: params.config,
|
||||
agentDir,
|
||||
agentDir: params.agentDir ?? resolveDefaultAgentDir(params.config),
|
||||
});
|
||||
if (!shouldUseConfiguredCodexSyntheticUsage({ config: params.config, agentDir })) {
|
||||
return usage;
|
||||
}
|
||||
const codexUsage = await loadProviderUsageSummary({
|
||||
timeoutMs: params.timeoutMs,
|
||||
providers: ["openai"],
|
||||
auth: [buildCodexSyntheticUsageAuth()],
|
||||
config: params.config,
|
||||
agentDir,
|
||||
});
|
||||
return mergeUsageSummaries(usage, codexUsage);
|
||||
}
|
||||
|
||||
/** Exposes the lazily loaded provider-usage module for callers that need its helpers. */
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
|
||||
|
||||
const envSnapshot = captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
||||
|
||||
const tempHomes: string[] = [];
|
||||
|
||||
function useTempHome(): string {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-runtime-"));
|
||||
tempHomes.push(home);
|
||||
setTestEnvValue("HOME", home);
|
||||
setTestEnvValue("OPENCLAW_HOME", home);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", "");
|
||||
return home;
|
||||
}
|
||||
|
||||
function execApprovalsPath(home: string): string {
|
||||
return path.join(home, ".openclaw", "exec-approvals.json");
|
||||
}
|
||||
|
||||
function readExecApprovals(home: string): {
|
||||
socket?: { token?: string };
|
||||
} {
|
||||
return JSON.parse(fs.readFileSync(execApprovalsPath(home), "utf8")) as {
|
||||
socket?: { token?: string };
|
||||
};
|
||||
}
|
||||
|
||||
async function importRuntimeTokenModule(): Promise<
|
||||
typeof import("./agent-runtime-identity-token.js")
|
||||
> {
|
||||
vi.resetModules();
|
||||
return await import("./agent-runtime-identity-token.js");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
envSnapshot.restore();
|
||||
for (const home of tempHomes.splice(0)) {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("agent runtime identity token", () => {
|
||||
it("persists the local signing secret so tokens verify across processes", async () => {
|
||||
const home = useTempHome();
|
||||
const firstProcess = await importRuntimeTokenModule();
|
||||
|
||||
const token = firstProcess.mintAgentRuntimeIdentityToken({
|
||||
agentId: "main",
|
||||
sessionKey: "session-1",
|
||||
});
|
||||
|
||||
const persistedToken = readExecApprovals(home).socket?.token;
|
||||
expect(persistedToken).toEqual(expect.any(String));
|
||||
expect(persistedToken).not.toHaveLength(0);
|
||||
|
||||
const secondProcess = await importRuntimeTokenModule();
|
||||
expect(secondProcess.verifyAgentRuntimeIdentityToken(token)).toEqual({
|
||||
kind: "agentRuntime",
|
||||
agentId: "main",
|
||||
sessionKey: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mint local credentials while rejecting invalid presented tokens", async () => {
|
||||
const home = useTempHome();
|
||||
const runtimeToken = await importRuntimeTokenModule();
|
||||
|
||||
expect(runtimeToken.verifyAgentRuntimeIdentityToken("not-a-valid-token")).toBeUndefined();
|
||||
expect(fs.existsSync(execApprovalsPath(home))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects tokens minted from a different local state directory", async () => {
|
||||
const firstHome = useTempHome();
|
||||
const firstProcess = await importRuntimeTokenModule();
|
||||
const token = firstProcess.mintAgentRuntimeIdentityToken({
|
||||
agentId: "main",
|
||||
sessionKey: "session-1",
|
||||
});
|
||||
expect(fs.existsSync(execApprovalsPath(firstHome))).toBe(true);
|
||||
|
||||
useTempHome();
|
||||
const secondProcess = await importRuntimeTokenModule();
|
||||
const secondToken = secondProcess.mintAgentRuntimeIdentityToken({
|
||||
agentId: "main",
|
||||
sessionKey: "session-1",
|
||||
});
|
||||
|
||||
expect(secondToken).not.toBe(token);
|
||||
expect(secondProcess.verifyAgentRuntimeIdentityToken(token)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
// Purpose-scoped local agent runtime identity token for Gateway clients.
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { ensureExecApprovals, loadExecApprovals } from "../infra/exec-approvals.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
||||
const AGENT_RUNTIME_IDENTITY_TOKEN_CONTEXT = "openclaw:gateway-agent-runtime-identity-token:v1";
|
||||
const AGENT_RUNTIME_IDENTITY_TOKEN_KIND = "agent-runtime";
|
||||
|
||||
export type AgentRuntimeIdentity = {
|
||||
kind: "agentRuntime";
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
type AgentRuntimeIdentityTokenPayload = {
|
||||
kind: typeof AGENT_RUNTIME_IDENTITY_TOKEN_KIND;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
function readSharedAgentRuntimeIdentitySecret(): string | null {
|
||||
return loadExecApprovals().socket?.token?.trim() || null;
|
||||
}
|
||||
|
||||
function requireSharedAgentRuntimeIdentitySecret(): string {
|
||||
const token = ensureExecApprovals().socket?.token?.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Unable to mint agent runtime identity token without local socket credentials.",
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function signPayload(secret: string, payload: string): string {
|
||||
return createHmac("sha256", secret)
|
||||
.update(AGENT_RUNTIME_IDENTITY_TOKEN_CONTEXT)
|
||||
.update("\0")
|
||||
.update(payload)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
function signatureMatches(value: string, expected: string): boolean {
|
||||
const valueBytes = Buffer.from(value);
|
||||
const expectedBytes = Buffer.from(expected);
|
||||
return valueBytes.length === expectedBytes.length && timingSafeEqual(valueBytes, expectedBytes);
|
||||
}
|
||||
|
||||
function encodePayload(payload: AgentRuntimeIdentityTokenPayload): string {
|
||||
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodePayload(value: string): AgentRuntimeIdentityTokenPayload | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const raw = parsed as {
|
||||
kind?: unknown;
|
||||
agentId?: unknown;
|
||||
sessionKey?: unknown;
|
||||
};
|
||||
if (
|
||||
raw.kind !== AGENT_RUNTIME_IDENTITY_TOKEN_KIND ||
|
||||
typeof raw.agentId !== "string" ||
|
||||
typeof raw.sessionKey !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = normalizeAgentId(raw.agentId);
|
||||
const sessionKey = raw.sessionKey.trim();
|
||||
if (!agentId || !sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND, agentId, sessionKey };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint an opaque token that lets trusted local agent-tool clients identify their agent. */
|
||||
export function mintAgentRuntimeIdentityToken(params: {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
}): string {
|
||||
const payload = encodePayload({
|
||||
kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND,
|
||||
agentId: normalizeAgentId(params.agentId),
|
||||
sessionKey: params.sessionKey.trim(),
|
||||
});
|
||||
const signature = signPayload(requireSharedAgentRuntimeIdentitySecret(), payload);
|
||||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
/** Validate a presented agent runtime token and return the internal caller identity. */
|
||||
export function verifyAgentRuntimeIdentityToken(
|
||||
value: string | null | undefined,
|
||||
): AgentRuntimeIdentity | undefined {
|
||||
const token = value?.trim();
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
const [payloadPart, signature, ...extra] = token.split(".");
|
||||
if (!payloadPart || !signature || extra.length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
const payload = decodePayload(payloadPart);
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
const sharedSecret = readSharedAgentRuntimeIdentitySecret();
|
||||
if (!sharedSecret || !signatureMatches(signature, signPayload(sharedSecret, payloadPart))) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: "agentRuntime",
|
||||
agentId: payload.agentId,
|
||||
sessionKey: payload.sessionKey,
|
||||
};
|
||||
}
|
||||
@@ -85,7 +85,6 @@ type CallGatewayBaseOptions = {
|
||||
platform?: string;
|
||||
mode?: GatewayClientMode;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
useStoredDeviceAuth?: boolean;
|
||||
requiredStoredDeviceAuthScopes?: OperatorScope[];
|
||||
requireLocalBackendSharedAuth?: boolean;
|
||||
@@ -990,9 +989,6 @@ async function executeGatewayRequestWithScopes<T>(params: {
|
||||
platform: opts.platform,
|
||||
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
|
||||
...(opts.approvalRuntimeToken ? { approvalRuntimeToken: opts.approvalRuntimeToken } : {}),
|
||||
...(opts.agentRuntimeIdentityToken
|
||||
? { agentRuntimeIdentityToken: opts.agentRuntimeIdentityToken }
|
||||
: {}),
|
||||
role: "operator",
|
||||
...(Array.isArray(scopes) ? { scopes } : {}),
|
||||
deviceIdentity,
|
||||
|
||||
@@ -1174,7 +1174,6 @@ describe("GatewayClient connect auth payload", () => {
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -1471,44 +1470,6 @@ describe("GatewayClient connect auth payload", () => {
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("fails closed when a gateway rejects the required agent runtime identity auth field", async () => {
|
||||
const onConnectError = vi.fn();
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "shared-token",
|
||||
agentRuntimeIdentityToken: "identity-token",
|
||||
deviceIdentity: null,
|
||||
onConnectError,
|
||||
});
|
||||
|
||||
const { ws, connect } = startClientAndConnect({ client });
|
||||
expectRecordFields(
|
||||
connect.params?.auth ?? {},
|
||||
{
|
||||
token: "shared-token",
|
||||
agentRuntimeIdentityToken: "identity-token",
|
||||
},
|
||||
"initial connect auth",
|
||||
);
|
||||
|
||||
await expectNoReconnectAfterConnectFailure({
|
||||
client,
|
||||
firstWs: ws,
|
||||
connectId: connect.id,
|
||||
failureDetails: {},
|
||||
failureMessage:
|
||||
"invalid connect params: at /auth: unexpected property 'agentRuntimeIdentityToken'",
|
||||
});
|
||||
const error = firstMockArg(onConnectError, "connect error") as Error;
|
||||
expect(error.message).toBe(
|
||||
"gateway rejected required agent runtime identity auth field; refusing to retry without it",
|
||||
);
|
||||
expect(ws.lastClose).toEqual({ code: 1008, reason: "connect failed" });
|
||||
expect(logErrorMock).toHaveBeenCalledWith(
|
||||
"gateway connect failed: gateway rejected required agent runtime identity auth field; refusing to retry without it",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for socket open before sending connect after an early challenge", () => {
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
|
||||
@@ -131,7 +131,6 @@ export type GatewayClientOptions = {
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
instanceId?: string;
|
||||
clientName?: GatewayClientName;
|
||||
clientDisplayName?: string;
|
||||
|
||||
@@ -23,10 +23,6 @@ import {
|
||||
readCronRunLogEntriesPageAll,
|
||||
} from "../../cron/run-log.js";
|
||||
import { applyJobPatch } from "../../cron/service/jobs.js";
|
||||
import type {
|
||||
CronListPageOptions,
|
||||
CronListPageResult,
|
||||
} from "../../cron/service/list-page-types.js";
|
||||
import { isInvalidCronSessionTargetIdError } from "../../cron/session-target.js";
|
||||
import type { CronDelivery, CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js";
|
||||
import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js";
|
||||
@@ -36,22 +32,13 @@ import {
|
||||
resolveTargetPrefixedChannel,
|
||||
validateTargetProviderPrefix,
|
||||
} from "../../infra/outbound/channel-target-prefix.js";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
isSubagentSessionKey,
|
||||
normalizeAgentId,
|
||||
} from "../../routing/session-key.js";
|
||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
|
||||
import {
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import type { GatewayClient, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
type CronCallerScope = {
|
||||
kind: "agentTool";
|
||||
agentId: string;
|
||||
};
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
type CronJobIdParams = { id?: string; jobId?: string };
|
||||
|
||||
@@ -68,13 +55,6 @@ type CronRunsRequestParams = CronJobIdParams & {
|
||||
sortDir?: "asc" | "desc";
|
||||
};
|
||||
|
||||
type CronListCallerScopeContext = {
|
||||
cron: {
|
||||
getDefaultAgentId(): string | undefined;
|
||||
listPage(opts?: CronListPageOptions): Promise<CronListPageResult>;
|
||||
};
|
||||
};
|
||||
|
||||
function compactCronListJob(job: CronJob) {
|
||||
return {
|
||||
id: job.id,
|
||||
@@ -86,171 +66,6 @@ function compactCronListJob(job: CronJob) {
|
||||
};
|
||||
}
|
||||
|
||||
function readCronCallerScope(
|
||||
client: GatewayClient | null | undefined,
|
||||
): CronCallerScope | undefined {
|
||||
const identity = client?.internal?.agentRuntimeIdentity;
|
||||
if (!identity?.agentId) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind: "agentTool", agentId: normalizeAgentId(identity.agentId) };
|
||||
}
|
||||
|
||||
function resolveCronJobEffectiveAgentId(job: CronJob, defaultAgentId?: string): string {
|
||||
return normalizeAgentId(job.agentId ?? defaultAgentId ?? DEFAULT_AGENT_ID);
|
||||
}
|
||||
|
||||
function parseAgentIdFromSessionRef(value: string | undefined | null): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return parseAgentSessionKey(trimmed)?.agentId;
|
||||
}
|
||||
|
||||
function parseAgentIdFromCronSessionTarget(value: string | undefined | null): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed?.startsWith("session:")) {
|
||||
return undefined;
|
||||
}
|
||||
return parseAgentIdFromSessionRef(trimmed.slice("session:".length));
|
||||
}
|
||||
|
||||
function cronJobSessionRefsMatchCaller(job: CronJob, callerScope: CronCallerScope): boolean {
|
||||
const sessionAgentId = parseAgentIdFromSessionRef(job.sessionKey);
|
||||
if (sessionAgentId && normalizeAgentId(sessionAgentId) !== callerScope.agentId) {
|
||||
return false;
|
||||
}
|
||||
const sessionTargetAgentId = parseAgentIdFromCronSessionTarget(job.sessionTarget);
|
||||
return !sessionTargetAgentId || normalizeAgentId(sessionTargetAgentId) === callerScope.agentId;
|
||||
}
|
||||
|
||||
function cronJobMatchesCallerScope(params: {
|
||||
job: CronJob;
|
||||
callerScope: CronCallerScope | undefined;
|
||||
defaultAgentId?: string;
|
||||
}): boolean {
|
||||
if (!params.callerScope) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
resolveCronJobEffectiveAgentId(params.job, params.defaultAgentId) !== params.callerScope.agentId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return cronJobSessionRefsMatchCaller(params.job, params.callerScope);
|
||||
}
|
||||
|
||||
function cronCreateMatchesCallerScope(params: {
|
||||
job: CronJobCreate;
|
||||
callerScope: CronCallerScope | undefined;
|
||||
defaultAgentId?: string;
|
||||
}): boolean {
|
||||
if (!params.callerScope) {
|
||||
return true;
|
||||
}
|
||||
const effectiveAgentId = normalizeAgentId(
|
||||
params.job.agentId ?? params.defaultAgentId ?? DEFAULT_AGENT_ID,
|
||||
);
|
||||
if (effectiveAgentId !== params.callerScope.agentId) {
|
||||
return false;
|
||||
}
|
||||
const sessionAgentId = parseAgentIdFromSessionRef(params.job.sessionKey);
|
||||
if (sessionAgentId && normalizeAgentId(sessionAgentId) !== params.callerScope.agentId) {
|
||||
return false;
|
||||
}
|
||||
const sessionTargetAgentId = parseAgentIdFromCronSessionTarget(params.job.sessionTarget);
|
||||
return (
|
||||
!sessionTargetAgentId || normalizeAgentId(sessionTargetAgentId) === params.callerScope.agentId
|
||||
);
|
||||
}
|
||||
|
||||
function applyCronCreateCallerScopeDefault(
|
||||
job: CronJobCreate,
|
||||
callerScope: CronCallerScope | undefined,
|
||||
): CronJobCreate {
|
||||
if (!callerScope || "agentId" in job) {
|
||||
return job;
|
||||
}
|
||||
return {
|
||||
...job,
|
||||
agentId: callerScope.agentId,
|
||||
};
|
||||
}
|
||||
|
||||
function cronPatchSessionRefsMatchCaller(
|
||||
patch: CronJobPatch,
|
||||
callerScope: CronCallerScope | undefined,
|
||||
): boolean {
|
||||
if (!callerScope) {
|
||||
return true;
|
||||
}
|
||||
const sessionAgentId =
|
||||
"sessionKey" in patch && typeof patch.sessionKey === "string"
|
||||
? parseAgentIdFromSessionRef(patch.sessionKey)
|
||||
: undefined;
|
||||
if (sessionAgentId && normalizeAgentId(sessionAgentId) !== callerScope.agentId) {
|
||||
return false;
|
||||
}
|
||||
const sessionTargetAgentId =
|
||||
"sessionTarget" in patch && typeof patch.sessionTarget === "string"
|
||||
? parseAgentIdFromCronSessionTarget(patch.sessionTarget)
|
||||
: undefined;
|
||||
return !sessionTargetAgentId || normalizeAgentId(sessionTargetAgentId) === callerScope.agentId;
|
||||
}
|
||||
|
||||
async function listCronPageForCallerScope({
|
||||
callerScope,
|
||||
context,
|
||||
options,
|
||||
}: {
|
||||
callerScope: CronCallerScope;
|
||||
context: CronListCallerScopeContext;
|
||||
options: CronListPageOptions;
|
||||
}): Promise<CronListPageResult> {
|
||||
const scopedJobs: CronJob[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (;;) {
|
||||
const sourcePage = await context.cron.listPage({
|
||||
...options,
|
||||
agentId: callerScope.agentId,
|
||||
limit: 200,
|
||||
offset,
|
||||
});
|
||||
|
||||
scopedJobs.push(
|
||||
...sourcePage.jobs.filter((job) =>
|
||||
cronJobMatchesCallerScope({
|
||||
job,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (!sourcePage.hasMore || sourcePage.nextOffset === null || sourcePage.nextOffset <= offset) {
|
||||
break;
|
||||
}
|
||||
offset = sourcePage.nextOffset;
|
||||
}
|
||||
|
||||
const total = scopedJobs.length;
|
||||
const pageOffset = Math.max(0, Math.min(total, Math.floor(options.offset ?? 0)));
|
||||
const defaultLimit = total === 0 ? 50 : total;
|
||||
const limit = Math.max(1, Math.min(200, Math.floor(options.limit ?? defaultLimit)));
|
||||
const jobs = scopedJobs.slice(pageOffset, pageOffset + limit);
|
||||
const nextOffset = pageOffset + jobs.length;
|
||||
return {
|
||||
jobs,
|
||||
total,
|
||||
offset: pageOffset,
|
||||
limit,
|
||||
hasMore: nextOffset < total,
|
||||
nextOffset: nextOffset < total ? nextOffset : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function listConfiguredAnnounceChannelIds(cfg: OpenClawConfig): Promise<string[]> {
|
||||
return await listConfiguredMessageChannels(cfg);
|
||||
}
|
||||
@@ -535,7 +350,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"cron.list": async ({ params, respond, context, client }) => {
|
||||
"cron.list": async ({ params, respond, context }) => {
|
||||
if (!validateCronListParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
@@ -560,13 +375,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
agentId?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const requestedAgentId = p.agentId ? normalizeAgentId(p.agentId) : undefined;
|
||||
if (callerScope && requestedAgentId && requestedAgentId !== callerScope.agentId) {
|
||||
respondInvalidCronParams(respond, "cron.list", "agentId outside caller scope");
|
||||
return;
|
||||
}
|
||||
const listOptions = {
|
||||
const page = await context.cron.listPage({
|
||||
includeDisabled: p.includeDisabled,
|
||||
limit: p.limit,
|
||||
offset: p.offset,
|
||||
@@ -576,15 +385,8 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
lastRunStatus: p.lastRunStatus,
|
||||
sortBy: p.sortBy,
|
||||
sortDir: p.sortDir,
|
||||
agentId: callerScope?.agentId ?? p.agentId,
|
||||
};
|
||||
const page = callerScope
|
||||
? await listCronPageForCallerScope({
|
||||
callerScope,
|
||||
context,
|
||||
options: listOptions,
|
||||
})
|
||||
: await context.cron.listPage(listOptions);
|
||||
agentId: p.agentId,
|
||||
});
|
||||
if (p.compact === true) {
|
||||
respond(true, { ...page, jobs: page.jobs.map(compactCronListJob) }, undefined);
|
||||
return;
|
||||
@@ -611,7 +413,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
const status = await context.cron.status();
|
||||
respond(true, status, undefined);
|
||||
},
|
||||
"cron.get": async ({ params, respond, context, client }) => {
|
||||
"cron.get": async ({ params, respond, context }) => {
|
||||
if (!validateCronGetParams(params)) {
|
||||
respondInvalidCronParams(
|
||||
respond,
|
||||
@@ -625,16 +427,8 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
respondMissingCronJobId(respond, "cron.get");
|
||||
return;
|
||||
}
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const job = await context.cron.readJob(jobId);
|
||||
if (
|
||||
!job ||
|
||||
!cronJobMatchesCallerScope({
|
||||
job,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
})
|
||||
) {
|
||||
if (!job) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -644,7 +438,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
respond(true, job, undefined);
|
||||
},
|
||||
"cron.add": async ({ params, respond, context, client }) => {
|
||||
"cron.add": async ({ params, respond, context }) => {
|
||||
const sessionKey =
|
||||
typeof (params as { sessionKey?: unknown } | null)?.sessionKey === "string"
|
||||
? (params as { sessionKey: string }).sessionKey
|
||||
@@ -667,8 +461,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const candidate = normalized;
|
||||
if (!validateCronAddParams(candidate)) {
|
||||
if (!validateCronAddParams(normalized)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -679,19 +472,8 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const jobCreate = applyCronCreateCallerScopeDefault(candidate as CronJobCreate, callerScope);
|
||||
const jobCreate = normalized as unknown as CronJobCreate;
|
||||
const cfg = context.getRuntimeConfig();
|
||||
if (
|
||||
!cronCreateMatchesCallerScope({
|
||||
job: jobCreate,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
})
|
||||
) {
|
||||
respondInvalidCronParams(respond, "cron.add", "job agentId outside caller scope");
|
||||
return;
|
||||
}
|
||||
const timestampValidation = validateScheduleTimestamp(jobCreate.schedule);
|
||||
if (!timestampValidation.ok) {
|
||||
respond(
|
||||
@@ -738,7 +520,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
context.logGateway.info("cron: job created", { jobId: job.id, schedule: jobCreate.schedule });
|
||||
respond(true, job, undefined);
|
||||
},
|
||||
"cron.update": async ({ params, respond, context, client }) => {
|
||||
"cron.update": async ({ params, respond, context }) => {
|
||||
let normalizedPatch: ReturnType<typeof normalizeCronJobPatch>;
|
||||
try {
|
||||
const rawPatch = (params as { patch?: unknown } | null)?.patch;
|
||||
@@ -779,7 +561,6 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
jobId?: string;
|
||||
patch: Record<string, unknown>;
|
||||
};
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const jobId = p.id ?? p.jobId;
|
||||
if (!jobId) {
|
||||
respond(
|
||||
@@ -792,25 +573,10 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
const patch = p.patch as unknown as CronJobPatch;
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const currentJob = await context.cron.readJob(jobId);
|
||||
if (
|
||||
!currentJob ||
|
||||
!cronJobMatchesCallerScope({
|
||||
job: currentJob,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
})
|
||||
) {
|
||||
if (!currentJob) {
|
||||
respondInvalidCronParams(respond, "cron.update", "id not found");
|
||||
return;
|
||||
}
|
||||
if (callerScope && "agentId" in patch) {
|
||||
respondInvalidCronParams(respond, "cron.update", "agentId cannot be changed by caller scope");
|
||||
return;
|
||||
}
|
||||
if (!cronPatchSessionRefsMatchCaller(patch, callerScope)) {
|
||||
respondInvalidCronParams(respond, "cron.update", "session target outside caller scope");
|
||||
return;
|
||||
}
|
||||
if (patch.schedule) {
|
||||
const timestampValidation = validateScheduleTimestamp(patch.schedule);
|
||||
if (!timestampValidation.ok) {
|
||||
@@ -864,7 +630,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
context.logGateway.info("cron: job updated", { jobId });
|
||||
respond(true, job, undefined);
|
||||
},
|
||||
"cron.remove": async ({ params, respond, context, client }) => {
|
||||
"cron.remove": async ({ params, respond, context }) => {
|
||||
if (!validateCronRemoveParams(params)) {
|
||||
respondInvalidCronParams(
|
||||
respond,
|
||||
@@ -878,23 +644,6 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
respondMissingCronJobId(respond, "cron.remove");
|
||||
return;
|
||||
}
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const job = await context.cron.readJob(jobId);
|
||||
if (
|
||||
!job ||
|
||||
!cronJobMatchesCallerScope({
|
||||
job,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
})
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.remove params: id not found"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await context.cron.remove(jobId);
|
||||
if (!result.removed) {
|
||||
respond(
|
||||
@@ -907,7 +656,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
context.logGateway.info("cron: job removed", { jobId });
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"cron.run": async ({ params, respond, context, client }) => {
|
||||
"cron.run": async ({ params, respond, context }) => {
|
||||
if (!validateCronRunParams(params)) {
|
||||
respondInvalidCronParams(
|
||||
respond,
|
||||
@@ -917,24 +666,11 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const p = params as CronJobIdParams & { mode?: "due" | "force" };
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const jobId = resolveCronJobId(p);
|
||||
if (!jobId) {
|
||||
respondMissingCronJobId(respond, "cron.run");
|
||||
return;
|
||||
}
|
||||
const job = await context.cron.readJob(jobId);
|
||||
if (
|
||||
!job ||
|
||||
!cronJobMatchesCallerScope({
|
||||
job,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
})
|
||||
) {
|
||||
respondInvalidCronParams(respond, "cron.run", "id not found");
|
||||
return;
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof context.cron.enqueueRun>>;
|
||||
try {
|
||||
result = await context.cron.enqueueRun(jobId, p.mode ?? "force");
|
||||
@@ -951,7 +687,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"cron.runs": async ({ params, respond, context, client }) => {
|
||||
"cron.runs": async ({ params, respond, context }) => {
|
||||
if (!validateCronRunsParams(params)) {
|
||||
respondInvalidCronParams(
|
||||
respond,
|
||||
@@ -961,7 +697,6 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const p = params as CronRunsRequestParams;
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const explicitScope = p.scope;
|
||||
const jobId = resolveCronJobId(p);
|
||||
const scope: "job" | "all" = explicitScope ?? (jobId ? "job" : "all");
|
||||
@@ -970,10 +705,6 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
if (scope === "all") {
|
||||
if (callerScope) {
|
||||
respondInvalidCronParams(respond, "cron.runs", "scope all is not allowed by caller scope");
|
||||
return;
|
||||
}
|
||||
const jobs = await context.cron.list({ includeDisabled: true });
|
||||
const jobNameById = Object.fromEntries(
|
||||
jobs
|
||||
@@ -990,19 +721,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
try {
|
||||
const jobs = await context.cron.list({ includeDisabled: true });
|
||||
const matchedJob = jobs.find(
|
||||
(job) =>
|
||||
job.id === jobId &&
|
||||
cronJobMatchesCallerScope({
|
||||
job,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
}),
|
||||
);
|
||||
if (callerScope && !matchedJob) {
|
||||
respondInvalidCronParams(respond, "cron.runs", "id not found");
|
||||
return;
|
||||
}
|
||||
const matchedJob = jobs.find((job) => job.id === jobId);
|
||||
const jobNameById =
|
||||
matchedJob && typeof matchedJob.name === "string"
|
||||
? { [jobId as string]: matchedJob.name }
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import type { GatewayClient } from "./types.js";
|
||||
|
||||
const getRuntimeConfig = vi.hoisted(() =>
|
||||
vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig),
|
||||
@@ -81,8 +80,7 @@ function setCronValidationTestRegistry(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function createCronContext(currentJobs?: CronJob | CronJob[]) {
|
||||
const jobs = currentJobs ? (Array.isArray(currentJobs) ? currentJobs : [currentJobs]) : [];
|
||||
function createCronContext(currentJob?: CronJob) {
|
||||
return {
|
||||
cron: {
|
||||
add: vi.fn(async () => ({ id: "cron-1" })),
|
||||
@@ -90,30 +88,9 @@ function createCronContext(currentJobs?: CronJob | CronJob[]) {
|
||||
remove: vi.fn(async () => ({ ok: true, removed: true })),
|
||||
enqueueRun: vi.fn(async () => ({ ok: true, enqueued: true, runId: "run-1" })),
|
||||
getDefaultAgentId: vi.fn(() => "main"),
|
||||
getJob: vi.fn((id: string) => jobs.find((job) => job.id === id)),
|
||||
getJob: vi.fn(() => currentJob),
|
||||
wake: vi.fn(() => ({ ok: true }) as const),
|
||||
readJob: vi.fn(async (id: string) => jobs.find((job) => job.id === id)),
|
||||
list: vi.fn(async () => jobs),
|
||||
listPage: vi.fn(async (opts?: { agentId?: string; limit?: number; offset?: number }) => {
|
||||
const requestedAgentId = opts?.agentId?.trim().toLowerCase();
|
||||
const filteredJobs = requestedAgentId
|
||||
? jobs.filter((job) => (job.agentId ?? "main").trim().toLowerCase() === requestedAgentId)
|
||||
: jobs;
|
||||
const total = filteredJobs.length;
|
||||
const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0)));
|
||||
const defaultLimit = total === 0 ? 50 : total;
|
||||
const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? defaultLimit)));
|
||||
const pageJobs = filteredJobs.slice(offset, offset + limit);
|
||||
const nextOffset = offset + pageJobs.length;
|
||||
return {
|
||||
jobs: pageJobs,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
hasMore: nextOffset < total,
|
||||
nextOffset: nextOffset < total ? nextOffset : null,
|
||||
};
|
||||
}),
|
||||
readJob: vi.fn(async (id: string) => (id === currentJob?.id ? currentJob : undefined)),
|
||||
},
|
||||
logGateway: {
|
||||
info: vi.fn(),
|
||||
@@ -127,11 +104,7 @@ type CronMethod = keyof typeof cronHandlers;
|
||||
async function invokeCron(
|
||||
method: CronMethod,
|
||||
params: Record<string, unknown>,
|
||||
options: {
|
||||
currentJob?: CronJob;
|
||||
context?: ReturnType<typeof createCronContext>;
|
||||
client?: GatewayClient;
|
||||
} = {},
|
||||
options: { currentJob?: CronJob; context?: ReturnType<typeof createCronContext> } = {},
|
||||
) {
|
||||
const context = options.context ?? createCronContext(options.currentJob);
|
||||
const respond = vi.fn();
|
||||
@@ -140,33 +113,22 @@ async function invokeCron(
|
||||
params: params as never,
|
||||
respond: respond as never,
|
||||
context: context as never,
|
||||
client: options.client ?? null,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return { context, respond };
|
||||
}
|
||||
|
||||
async function invokeCronAdd(
|
||||
params: Record<string, unknown>,
|
||||
options?: { client?: GatewayClient },
|
||||
) {
|
||||
return await invokeCron("cron.add", params, options);
|
||||
async function invokeCronAdd(params: Record<string, unknown>) {
|
||||
return await invokeCron("cron.add", params);
|
||||
}
|
||||
|
||||
async function invokeCronGet(
|
||||
params: Record<string, unknown>,
|
||||
currentJob?: CronJob,
|
||||
options?: { client?: GatewayClient },
|
||||
) {
|
||||
return await invokeCron("cron.get", params, { currentJob, ...options });
|
||||
async function invokeCronGet(params: Record<string, unknown>, currentJob?: CronJob) {
|
||||
return await invokeCron("cron.get", params, { currentJob });
|
||||
}
|
||||
|
||||
async function invokeCronUpdate(
|
||||
params: Record<string, unknown>,
|
||||
currentJob?: CronJob,
|
||||
options?: { client?: GatewayClient },
|
||||
) {
|
||||
return await invokeCron("cron.update", params, { currentJob, ...options });
|
||||
async function invokeCronUpdate(params: Record<string, unknown>, currentJob?: CronJob) {
|
||||
return await invokeCron("cron.update", params, { currentJob });
|
||||
}
|
||||
|
||||
async function invokeCronUpdateDelivery(
|
||||
@@ -184,13 +146,13 @@ async function invokeCronUpdateDelivery(
|
||||
|
||||
async function invokeCronRemove(
|
||||
params: Record<string, unknown>,
|
||||
options?: { removeResult?: { ok: boolean; removed: boolean }; client?: GatewayClient },
|
||||
options?: { removeResult?: { ok: boolean; removed: boolean } },
|
||||
) {
|
||||
const context = createCronContext();
|
||||
if (options?.removeResult) {
|
||||
context.cron.remove.mockResolvedValueOnce(options.removeResult);
|
||||
}
|
||||
return await invokeCron("cron.remove", params, { context, client: options?.client });
|
||||
return await invokeCron("cron.remove", params, { context });
|
||||
}
|
||||
|
||||
async function invokeWake(params: Record<string, unknown>) {
|
||||
@@ -214,19 +176,6 @@ function createCronJob(overrides: Partial<CronJob> = {}): CronJob {
|
||||
};
|
||||
}
|
||||
|
||||
function callerClient(agentId: string): GatewayClient {
|
||||
return {
|
||||
connect: {} as GatewayClient["connect"],
|
||||
internal: {
|
||||
agentRuntimeIdentity: {
|
||||
kind: "agentRuntime",
|
||||
agentId,
|
||||
sessionKey: `agent:${agentId}:main`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function telegramDeliveryWithSlackFailure(overrides: Partial<CronDelivery> = {}): CronDelivery {
|
||||
return {
|
||||
mode: "announce",
|
||||
@@ -443,35 +392,6 @@ describe("cron method validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows caller-scoped cron.remove for the same agent", async () => {
|
||||
const context = createCronContext(createCronJob({ id: "cron-1", agentId: "ops" }));
|
||||
|
||||
const { respond } = await invokeCron(
|
||||
"cron.remove",
|
||||
{ id: "cron-1" },
|
||||
{ context, client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.remove).toHaveBeenCalledWith("cron-1");
|
||||
expect(respond).toHaveBeenCalledWith(true, { ok: true, removed: true }, undefined);
|
||||
});
|
||||
|
||||
it("hides caller-scoped cron.remove for a foreign agent", async () => {
|
||||
const context = createCronContext(createCronJob({ id: "cron-1", agentId: "worker" }));
|
||||
|
||||
const { respond } = await invokeCron(
|
||||
"cron.remove",
|
||||
{ jobId: "cron-1" },
|
||||
{ context, client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.remove).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "invalid cron.remove params: id not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a single cron job for cron.get", async () => {
|
||||
const job = createCronJob({ id: "cron-42", name: "single job" });
|
||||
|
||||
@@ -481,46 +401,6 @@ describe("cron method validation", () => {
|
||||
expect(respond).toHaveBeenCalledWith(true, job, undefined);
|
||||
});
|
||||
|
||||
it("allows caller-scoped cron.get for the same agent", async () => {
|
||||
const job = createCronJob({ id: "cron-42", agentId: "ops" });
|
||||
|
||||
const { respond } = await invokeCronGet({ id: "cron-42" }, job, {
|
||||
client: callerClient("ops"),
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(true, job, undefined);
|
||||
});
|
||||
|
||||
it("hides caller-scoped cron.get for a foreign agent", async () => {
|
||||
const job = createCronJob({ id: "cron-42", agentId: "ops" });
|
||||
|
||||
const { respond } = await invokeCronGet({ jobId: "cron-42" }, job, {
|
||||
client: callerClient("worker"),
|
||||
});
|
||||
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "cron job not found: cron-42",
|
||||
});
|
||||
});
|
||||
|
||||
it("hides caller-scoped cron.get when stored sessionTarget points at a foreign agent", async () => {
|
||||
const job = createCronJob({
|
||||
id: "cron-42",
|
||||
agentId: "ops",
|
||||
sessionTarget: "session:agent:worker:telegram:direct:alice",
|
||||
});
|
||||
|
||||
const { respond } = await invokeCronGet({ id: "cron-42" }, job, {
|
||||
client: callerClient("ops"),
|
||||
});
|
||||
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "cron job not found: cron-42",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns INVALID_REQUEST when cron.get cannot find the job", async () => {
|
||||
const { respond } = await invokeCronGet({ jobId: "missing" });
|
||||
|
||||
@@ -530,150 +410,6 @@ describe("cron method validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes cron.list to the caller agent", async () => {
|
||||
const context = createCronContext(createCronJob({ agentId: "ops" }));
|
||||
|
||||
const { respond } = await invokeCron(
|
||||
"cron.list",
|
||||
{ includeDisabled: true, compact: true },
|
||||
{ context, client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.listPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeDisabled: true, agentId: "ops" }),
|
||||
);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ total: 1, jobs: expect.any(Array) }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects caller-scoped cron.list for a foreign explicit agentId", async () => {
|
||||
const context = createCronContext(createCronJob({ agentId: "ops" }));
|
||||
|
||||
const { respond } = await invokeCron(
|
||||
"cron.list",
|
||||
{ agentId: "worker" },
|
||||
{ context, client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.listPage).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "agentId outside caller scope",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unscoped cron.list agentId filtering global for operator callers", async () => {
|
||||
const context = createCronContext(createCronJob({ agentId: "worker" }));
|
||||
|
||||
const { respond } = await invokeCron("cron.list", { agentId: "worker" }, { context });
|
||||
|
||||
expect(context.cron.listPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: "worker" }),
|
||||
);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ total: 1, jobs: expect.any(Array) }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("filters caller-scoped cron.list jobs with foreign session targets before pagination", async () => {
|
||||
const foreignSessionJob = createCronJob({
|
||||
id: "cron-foreign",
|
||||
agentId: "ops",
|
||||
sessionTarget: "session:agent:worker:telegram:direct:alice",
|
||||
});
|
||||
const firstSafeJob = createCronJob({
|
||||
id: "cron-safe-1",
|
||||
agentId: "ops",
|
||||
sessionTarget: "session:agent:ops:telegram:direct:bob",
|
||||
});
|
||||
const secondSafeJob = createCronJob({
|
||||
id: "cron-safe-2",
|
||||
agentId: "ops",
|
||||
});
|
||||
const context = createCronContext([foreignSessionJob, firstSafeJob, secondSafeJob]);
|
||||
|
||||
const { respond } = await invokeCron(
|
||||
"cron.list",
|
||||
{ compact: true, limit: 1 },
|
||||
{ context, client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.listPage).toHaveBeenCalledWith(expect.objectContaining({ agentId: "ops" }));
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
hasMore: true,
|
||||
nextOffset: 1,
|
||||
jobs: [expect.objectContaining({ id: "cron-safe-1" })],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows internally scoped cron.add for the same agent without persisting caller identity", async () => {
|
||||
const { context, respond } = await invokeCronAdd(
|
||||
agentTurnCronParams({
|
||||
agentId: "ops",
|
||||
}),
|
||||
{ client: callerClient("ops") },
|
||||
);
|
||||
|
||||
const payload = requireCronAddPayload(context);
|
||||
expect(payload.agentId).toBe("ops");
|
||||
expect(payload).not.toHaveProperty("callerScope");
|
||||
expectCronSuccess(respond);
|
||||
});
|
||||
|
||||
it("defaults scoped cron.add ownership to the trusted caller when agentId is omitted", async () => {
|
||||
const { context, respond } = await invokeCronAdd(agentTurnCronParams(), {
|
||||
client: callerClient("ops"),
|
||||
});
|
||||
|
||||
const payload = requireCronAddPayload(context);
|
||||
expect(payload.agentId).toBe("ops");
|
||||
expect(payload).not.toHaveProperty("callerScope");
|
||||
expectCronSuccess(respond);
|
||||
});
|
||||
|
||||
it("rejects caller-scoped cron.add for a foreign agent", async () => {
|
||||
const { context, respond } = await invokeCronAdd(
|
||||
agentTurnCronParams({
|
||||
agentId: "worker",
|
||||
}),
|
||||
{ client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.add).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "outside caller scope",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects caller-scoped cron.add with a foreign agent-prefixed session target", async () => {
|
||||
const { context, respond } = await invokeCronAdd(
|
||||
agentTurnCronParams({
|
||||
agentId: "ops",
|
||||
sessionTarget: "session:agent:worker:telegram:direct:alice",
|
||||
}),
|
||||
{ client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.add).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "outside caller scope",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts threadId on announce delivery update params", async () => {
|
||||
setRuntimeConfig(telegramConfig());
|
||||
|
||||
@@ -704,84 +440,6 @@ describe("cron method validation", () => {
|
||||
expectCronSuccess(respond);
|
||||
});
|
||||
|
||||
it("allows caller-scoped cron.update for the same agent", async () => {
|
||||
const { context, respond } = await invokeCronUpdate(
|
||||
{
|
||||
id: "cron-1",
|
||||
patch: { enabled: false },
|
||||
},
|
||||
createCronJob({ agentId: "ops" }),
|
||||
{ client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.update).toHaveBeenCalledWith("cron-1", { enabled: false });
|
||||
expectCronSuccess(respond);
|
||||
});
|
||||
|
||||
it("hides caller-scoped cron.update for a foreign agent", async () => {
|
||||
const { context, respond } = await invokeCronUpdate(
|
||||
{
|
||||
id: "cron-1",
|
||||
patch: { enabled: false },
|
||||
},
|
||||
createCronJob({ agentId: "worker" }),
|
||||
{ client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.update).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "invalid cron.update params: id not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects caller-scoped cron.update agentId retargeting", async () => {
|
||||
const { context, respond } = await invokeCronUpdate(
|
||||
{
|
||||
id: "cron-1",
|
||||
patch: { agentId: "worker" },
|
||||
},
|
||||
createCronJob({ agentId: "ops" }),
|
||||
{ client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.update).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "agentId cannot be changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects caller-scoped cron.update with a foreign sessionTarget", async () => {
|
||||
const { context, respond } = await invokeCronUpdate(
|
||||
{
|
||||
id: "cron-1",
|
||||
patch: { sessionTarget: "session:agent:worker:telegram:direct:alice" },
|
||||
},
|
||||
createCronJob({ agentId: "ops" }),
|
||||
{ client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.update).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "session target outside caller scope",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unscoped cron.update agentId retargeting available for operator callers", async () => {
|
||||
const { context, respond } = await invokeCronUpdate(
|
||||
{
|
||||
id: "cron-1",
|
||||
patch: { agentId: "worker" },
|
||||
},
|
||||
createCronJob({ agentId: "ops" }),
|
||||
);
|
||||
|
||||
expect(context.cron.update).toHaveBeenCalledWith("cron-1", { agentId: "worker" });
|
||||
expectCronSuccess(respond);
|
||||
});
|
||||
|
||||
it("rejects execution-derived diagnostics in cron.update state patches", async () => {
|
||||
const { context, respond } = await invokeCronUpdate(
|
||||
{
|
||||
@@ -1413,74 +1071,9 @@ describe("cron method validation", () => {
|
||||
context.cron.enqueueRun.mockRejectedValueOnce(new Error("unknown cron job id: missing"));
|
||||
const { respond } = await invokeCron("cron.run", { id: "missing" }, { context });
|
||||
|
||||
expect(context.cron.enqueueRun).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "invalid cron.run params: id not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows caller-scoped cron.run for the same agent", async () => {
|
||||
const context = createCronContext(createCronJob({ id: "cron-1", agentId: "ops" }));
|
||||
|
||||
const { respond } = await invokeCron(
|
||||
"cron.run",
|
||||
{ id: "cron-1", mode: "due" },
|
||||
{ context, client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.enqueueRun).toHaveBeenCalledWith("cron-1", "due");
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ ok: true, enqueued: true, runId: "run-1" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides caller-scoped cron.run for a foreign agent", async () => {
|
||||
const context = createCronContext(createCronJob({ id: "cron-1", agentId: "worker" }));
|
||||
|
||||
const { respond } = await invokeCron(
|
||||
"cron.run",
|
||||
{ jobId: "cron-1" },
|
||||
{ context, client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.enqueueRun).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "invalid cron.run params: id not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects caller-scoped cron.runs all-scope history", async () => {
|
||||
const context = createCronContext(createCronJob({ id: "cron-1", agentId: "ops" }));
|
||||
|
||||
const { respond } = await invokeCron(
|
||||
"cron.runs",
|
||||
{ scope: "all" },
|
||||
{ context, client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expect(context.cron.list).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "scope all is not allowed by caller scope",
|
||||
});
|
||||
});
|
||||
|
||||
it("hides caller-scoped cron.runs for a foreign job", async () => {
|
||||
const context = createCronContext(createCronJob({ id: "cron-1", agentId: "worker" }));
|
||||
|
||||
const { respond } = await invokeCron(
|
||||
"cron.runs",
|
||||
{ id: "cron-1" },
|
||||
{ context, client: callerClient("ops") },
|
||||
);
|
||||
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "invalid cron.runs params: id not found",
|
||||
messageIncludes: "unknown cron job id: missing",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { CronServiceContract } from "../../cron/service-contract.js";
|
||||
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
|
||||
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { WizardSession } from "../../wizard/session.js";
|
||||
import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js";
|
||||
import type { ChatAbortControllerEntry } from "../chat-abort.js";
|
||||
import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js";
|
||||
import type { GatewayMethodRegistryView } from "../methods/descriptor.js";
|
||||
@@ -47,7 +46,6 @@ export type GatewayClient = {
|
||||
internal?: {
|
||||
allowModelOverride?: boolean;
|
||||
approvalRuntime?: boolean;
|
||||
agentRuntimeIdentity?: AgentRuntimeIdentity;
|
||||
pluginRuntimeOwnerId?: string;
|
||||
agentRunTracking?: "plugin_subagent";
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ type HandshakeConnectAuth = {
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
};
|
||||
|
||||
type DeviceTokenCandidateSource = "explicit-device-token" | "shared-token-fallback";
|
||||
|
||||
@@ -40,7 +40,6 @@ type HandshakeConnectAuth = {
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
};
|
||||
|
||||
function resolveBrowserOriginRateLimitKey(requestOrigin?: string): string {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
resetDiagnosticEventsForTest,
|
||||
type DiagnosticSecurityEvent,
|
||||
} from "../../../infra/diagnostic-events.js";
|
||||
import { mintAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js";
|
||||
import type { ResolvedGatewayAuth } from "../../auth.js";
|
||||
import { getOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js";
|
||||
import { handleGatewayRequest } from "../../server-methods.js";
|
||||
@@ -698,134 +697,6 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
||||
} | null;
|
||||
expect(connectedClient?.internal?.approvalRuntime).not.toBe(true);
|
||||
});
|
||||
|
||||
it("marks local backend clients with a valid agent runtime identity token", async () => {
|
||||
const refreshHealthSnapshot = vi.fn<GatewayRequestContext["refreshHealthSnapshot"]>(async () =>
|
||||
createHealthSummary(),
|
||||
);
|
||||
const harness = attachGatewayHarness({
|
||||
connId: "conn-agent-runtime-token",
|
||||
connectNonce: "nonce-agent-runtime-token",
|
||||
refreshHealthSnapshot,
|
||||
});
|
||||
|
||||
harness.sendConnect("connect-agent-runtime-token", {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
version: "dev",
|
||||
platform: "test",
|
||||
mode: "backend",
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.write"],
|
||||
caps: [],
|
||||
auth: {
|
||||
agentRuntimeIdentityToken: mintAgentRuntimeIdentityToken({
|
||||
agentId: "ops",
|
||||
sessionKey: "agent:ops:telegram:direct:alice",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.socketSend).toHaveBeenCalled();
|
||||
});
|
||||
const connectedClient = harness.client as {
|
||||
internal?: {
|
||||
agentRuntimeIdentity?: { agentId?: string; sessionKey?: string };
|
||||
};
|
||||
} | null;
|
||||
expect(connectedClient?.internal?.agentRuntimeIdentity).toMatchObject({
|
||||
agentId: "ops",
|
||||
sessionKey: "agent:ops:telegram:direct:alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects agent runtime identity tokens from remote clients", async () => {
|
||||
const refreshHealthSnapshot = vi.fn<GatewayRequestContext["refreshHealthSnapshot"]>(async () =>
|
||||
createHealthSummary(),
|
||||
);
|
||||
const close = createCloseMock();
|
||||
const harness = attachGatewayHarness({
|
||||
connId: "conn-remote-agent-runtime-token",
|
||||
connectNonce: "nonce-remote-agent-runtime-token",
|
||||
requestHost: "gateway.example.com:18789",
|
||||
remoteAddr: "203.0.113.50",
|
||||
resolvedAuth: {
|
||||
mode: "token",
|
||||
token: "gateway-token",
|
||||
allowTailscale: false,
|
||||
},
|
||||
refreshHealthSnapshot,
|
||||
close,
|
||||
});
|
||||
|
||||
harness.sendConnect("connect-remote-agent-runtime-token", {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
version: "dev",
|
||||
platform: "test",
|
||||
mode: "backend",
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.write"],
|
||||
caps: [],
|
||||
auth: {
|
||||
token: "gateway-token",
|
||||
agentRuntimeIdentityToken: mintAgentRuntimeIdentityToken({
|
||||
agentId: "ops",
|
||||
sessionKey: "agent:ops:telegram:direct:alice",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(close).toHaveBeenCalledWith(
|
||||
1008,
|
||||
"agent runtime identity token is only accepted from local backend gateway clients",
|
||||
);
|
||||
});
|
||||
expect(harness.client).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid local agent runtime identity tokens", async () => {
|
||||
const refreshHealthSnapshot = vi.fn<GatewayRequestContext["refreshHealthSnapshot"]>(async () =>
|
||||
createHealthSummary(),
|
||||
);
|
||||
const close = createCloseMock();
|
||||
const harness = attachGatewayHarness({
|
||||
connId: "conn-invalid-agent-runtime-token",
|
||||
connectNonce: "nonce-invalid-agent-runtime-token",
|
||||
refreshHealthSnapshot,
|
||||
close,
|
||||
});
|
||||
|
||||
harness.sendConnect("connect-invalid-agent-runtime-token", {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
version: "dev",
|
||||
platform: "test",
|
||||
mode: "backend",
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.write"],
|
||||
caps: [],
|
||||
auth: {
|
||||
agentRuntimeIdentityToken: "not-a-valid-token",
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(close).toHaveBeenCalledWith(1008, "invalid agent runtime identity token");
|
||||
});
|
||||
expect(harness.client).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePinnedClientMetadata", () => {
|
||||
|
||||
@@ -105,7 +105,6 @@ import {
|
||||
isWebchatClient,
|
||||
} from "../../../utils/message-channel.js";
|
||||
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
||||
import { verifyAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js";
|
||||
import { AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING, type AuthRateLimiter } from "../../auth-rate-limit.js";
|
||||
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
|
||||
import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js";
|
||||
@@ -1866,49 +1865,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
|
||||
connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND &&
|
||||
isOperatorApprovalRuntimeToken(connectParams.auth?.approvalRuntimeToken);
|
||||
const agentRuntimeIdentityToken = connectParams.auth?.agentRuntimeIdentityToken;
|
||||
const canAcceptAgentRuntimeIdentity =
|
||||
pairingLocality !== "remote" &&
|
||||
connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
|
||||
connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND;
|
||||
let trustedAgentRuntimeIdentity:
|
||||
| ReturnType<typeof verifyAgentRuntimeIdentityToken>
|
||||
| undefined;
|
||||
if (typeof agentRuntimeIdentityToken === "string") {
|
||||
if (!canAcceptAgentRuntimeIdentity) {
|
||||
const message =
|
||||
"agent runtime identity token is only accepted from local backend gateway clients";
|
||||
markHandshakeFailure("agent-runtime-identity-untrusted-client", {
|
||||
client: connectParams.client.id,
|
||||
mode: connectParams.client.mode,
|
||||
pairingLocality,
|
||||
});
|
||||
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, message);
|
||||
close(1008, truncateCloseReason(message));
|
||||
return;
|
||||
}
|
||||
trustedAgentRuntimeIdentity = verifyAgentRuntimeIdentityToken(agentRuntimeIdentityToken);
|
||||
if (!trustedAgentRuntimeIdentity) {
|
||||
const message = "invalid agent runtime identity token";
|
||||
markHandshakeFailure("agent-runtime-identity-invalid", {
|
||||
client: connectParams.client.id,
|
||||
mode: connectParams.client.mode,
|
||||
pairingLocality,
|
||||
});
|
||||
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, message);
|
||||
close(1008, message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const internal =
|
||||
isTrustedApprovalRuntime || trustedAgentRuntimeIdentity
|
||||
? {
|
||||
...(isTrustedApprovalRuntime ? { approvalRuntime: true } : {}),
|
||||
...(trustedAgentRuntimeIdentity
|
||||
? { agentRuntimeIdentity: trustedAgentRuntimeIdentity }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
clearHandshakeTimer();
|
||||
const nextClient: GatewayWsClient = {
|
||||
socket,
|
||||
@@ -1919,7 +1875,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration,
|
||||
presenceKey,
|
||||
clientIp: reportedClientIp,
|
||||
...(internal ? { internal } : {}),
|
||||
...(isTrustedApprovalRuntime ? { internal: { approvalRuntime: true } } : {}),
|
||||
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
|
||||
...(Object.keys(pluginNodeCapabilitySurfaces).length > 0
|
||||
? { pluginNodeCapabilitySurfaces }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Gateway WebSocket client types describe authenticated client state retained by the server.
|
||||
import type { WebSocket } from "ws";
|
||||
import type { ConnectParams } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js";
|
||||
import type { PluginNodeCapabilityClient } from "../plugin-node-capability.js";
|
||||
|
||||
/**
|
||||
@@ -18,7 +17,6 @@ export type GatewayWsClient = PluginNodeCapabilityClient & {
|
||||
clientIp?: string;
|
||||
internal?: {
|
||||
approvalRuntime?: boolean;
|
||||
agentRuntimeIdentity?: AgentRuntimeIdentity;
|
||||
};
|
||||
canvasHostUrl?: string;
|
||||
canvasCapability?: string;
|
||||
|
||||
@@ -116,8 +116,6 @@ describe("tsdown config", () => {
|
||||
"plugins/runtime/index",
|
||||
"plugins/synthetic-auth.runtime",
|
||||
"web-fetch/runtime",
|
||||
"mcp/openclaw-tools-serve",
|
||||
"mcp/plugin-tools-serve",
|
||||
"plugin-sdk/compat",
|
||||
"plugin-sdk/index",
|
||||
bundledEntry("active-memory"),
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
// OpenClaw MCP tools tests cover core tool server startup and registration.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
|
||||
resolveOpenClawToolsForMcp,
|
||||
resolveOpenClawToolsMcpAgentSessionKey,
|
||||
} from "./openclaw-tools-serve.js";
|
||||
import { resolveOpenClawToolsForMcp } from "./openclaw-tools-serve.js";
|
||||
import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js";
|
||||
|
||||
describe("OpenClaw tools MCP server", () => {
|
||||
it("exposes cron", async () => {
|
||||
const handlers = createPluginToolsMcpHandlers(
|
||||
resolveOpenClawToolsForMcp({ agentSessionKey: "agent:worker:main" }),
|
||||
);
|
||||
const handlers = createPluginToolsMcpHandlers(resolveOpenClawToolsForMcp());
|
||||
|
||||
const listed = await handlers.listTools();
|
||||
expect(listed.tools.map((tool) => tool.name)).toContain("cron");
|
||||
});
|
||||
|
||||
it("requires the managed bridge to pass a real agent session key", () => {
|
||||
expect(() => resolveOpenClawToolsForMcp({ agentSessionKey: "" })).toThrow(
|
||||
OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads the managed bridge agent session key from env", () => {
|
||||
expect(
|
||||
resolveOpenClawToolsMcpAgentSessionKey({
|
||||
[OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV]: " agent:worker:main ",
|
||||
}),
|
||||
).toBe("agent:worker:main");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,26 +11,8 @@ import { createCronTool } from "../agents/tools/cron-tool.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { connectToolsMcpServerToStdio, createToolsMcpServer } from "./tools-stdio-server.js";
|
||||
|
||||
export const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY";
|
||||
|
||||
export function resolveOpenClawToolsMcpAgentSessionKey(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
return env[OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV]?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function resolveOpenClawToolsForMcp(
|
||||
params: {
|
||||
agentSessionKey?: string;
|
||||
} = {},
|
||||
): AnyAgentTool[] {
|
||||
const agentSessionKey = (
|
||||
params.agentSessionKey ?? resolveOpenClawToolsMcpAgentSessionKey()
|
||||
)?.trim();
|
||||
if (!agentSessionKey) {
|
||||
throw new Error(`${OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV} is required`);
|
||||
}
|
||||
return [createCronTool({ agentSessionKey, creatorToolAllowlist: [{ name: "cron" }] })];
|
||||
export function resolveOpenClawToolsForMcp(): AnyAgentTool[] {
|
||||
return [createCronTool({ creatorToolAllowlist: [{ name: "cron" }] })];
|
||||
}
|
||||
|
||||
function createOpenClawToolsMcpServer(
|
||||
|
||||
@@ -1902,7 +1902,7 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls through to npm reinstall when metadata probing fails for valid specs", async () => {
|
||||
it("falls through to npm reinstall when metadata probing fails", async () => {
|
||||
const warn = vi.fn();
|
||||
const installPath = createInstalledPackageDir({
|
||||
name: "@martian-engineering/lossless-claw",
|
||||
@@ -1937,107 +1937,6 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records range metadata probing failures without falling through to npm reinstall", async () => {
|
||||
const warn = vi.fn();
|
||||
const installPath = createInstalledPackageDir({
|
||||
name: "@martian-engineering/lossless-claw",
|
||||
version: "0.9.0",
|
||||
});
|
||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "registry timeout",
|
||||
});
|
||||
const result = await updateNpmInstalledPlugins({
|
||||
config: createNpmInstallConfig({
|
||||
pluginId: "lossless-claw",
|
||||
spec: "@martian-engineering/lossless-claw@^0.9.0",
|
||||
installPath,
|
||||
}),
|
||||
pluginIds: ["lossless-claw"],
|
||||
logger: { warn },
|
||||
});
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.outcomes).toEqual([
|
||||
{
|
||||
pluginId: "lossless-claw",
|
||||
status: "error",
|
||||
message: "Failed to check lossless-claw: npm view failed: registry timeout",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses failure cleanup when metadata probing fails and disableOnFailure is enabled", async () => {
|
||||
const warn = vi.fn();
|
||||
const installPath = createInstalledPackageDir({
|
||||
name: "@martian-engineering/lossless-claw",
|
||||
version: "0.9.0",
|
||||
});
|
||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "registry timeout",
|
||||
});
|
||||
|
||||
const result = await updateNpmInstalledPlugins({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["lossless-claw", "keep"],
|
||||
deny: ["lossless-claw", "blocked"],
|
||||
slots: {
|
||||
memory: "lossless-claw",
|
||||
contextEngine: "lossless-claw",
|
||||
},
|
||||
entries: {
|
||||
"lossless-claw": {
|
||||
enabled: true,
|
||||
config: { preserved: true },
|
||||
},
|
||||
},
|
||||
installs: {
|
||||
"lossless-claw": {
|
||||
source: "npm",
|
||||
spec: "@martian-engineering/lossless-claw@^0.9.0",
|
||||
installPath,
|
||||
resolvedName: "@martian-engineering/lossless-claw",
|
||||
resolvedVersion: "0.9.0",
|
||||
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginIds: ["lossless-claw"],
|
||||
disableOnFailure: true,
|
||||
logger: { warn },
|
||||
});
|
||||
|
||||
const message =
|
||||
'Disabled "lossless-claw" after plugin update failure; OpenClaw will continue without it. Failed to check lossless-claw: npm view failed: registry timeout';
|
||||
expect(warn).toHaveBeenCalledWith(message);
|
||||
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.config.plugins?.entries?.["lossless-claw"]).toEqual({
|
||||
enabled: false,
|
||||
config: { preserved: true },
|
||||
});
|
||||
expect(result.config.plugins?.allow).toEqual(["keep"]);
|
||||
expect(result.config.plugins?.deny).toEqual(["blocked"]);
|
||||
expect(result.config.plugins?.slots).toEqual({
|
||||
memory: "memory-core",
|
||||
contextEngine: "legacy",
|
||||
});
|
||||
expect(result.outcomes).toEqual([
|
||||
{
|
||||
pluginId: "lossless-claw",
|
||||
status: "skipped",
|
||||
message,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
source: "npm",
|
||||
@@ -3965,7 +3864,6 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
it("reuses the recorded managed extensions root when updating external plugins", async () => {
|
||||
const installPath = "/var/openclaw/extensions/demo";
|
||||
const extensionsDir = "/var/openclaw/extensions";
|
||||
const expectedExtensionsDir = path.resolve(extensionsDir);
|
||||
installPluginFromNpmSpecMock.mockResolvedValue(
|
||||
createSuccessfulNpmUpdateResult({
|
||||
pluginId: "demo",
|
||||
@@ -4049,6 +3947,7 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
pluginIds: ["demo"],
|
||||
});
|
||||
|
||||
const expectedExtensionsDir = path.resolve(extensionsDir);
|
||||
expect(npmInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
|
||||
expect(clawHubInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
|
||||
expect(marketplaceInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
|
||||
|
||||
@@ -493,7 +493,7 @@ function resolveRecordedExtensionsDir(params: {
|
||||
const parentDir = path.dirname(params.installPath);
|
||||
try {
|
||||
const canonicalInstallPath = resolvePluginInstallDir(params.pluginId, parentDir);
|
||||
return pathsEqual(canonicalInstallPath, params.installPath) ? parentDir : undefined;
|
||||
return canonicalInstallPath === params.installPath ? parentDir : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1584,10 +1584,6 @@ export async function updateNpmInstalledPlugins(params: {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (!parseRegistryNpmSpec(effectiveSpec!)) {
|
||||
recordFailure(pluginId, `Failed to check ${pluginId}: ${metadataResult.error}`);
|
||||
continue;
|
||||
}
|
||||
logger.warn?.(
|
||||
`Could not check ${pluginId} before update; falling back to installer path: ${metadataResult.error}`,
|
||||
);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { CODEX_APP_SERVER_AUTH_MARKER } from "../agents/model-auth-markers.js";
|
||||
import type { ProviderAuth } from "../infra/provider-usage.auth.js";
|
||||
import type { ProviderUsageSnapshot, UsageSummary } from "../infra/provider-usage.types.js";
|
||||
|
||||
export const CODEX_SYNTHETIC_USAGE_PROVIDER = "openai";
|
||||
export const CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER = "codex";
|
||||
|
||||
export function buildCodexSyntheticUsageAuth(
|
||||
params: {
|
||||
authProfileId?: string;
|
||||
} = {},
|
||||
): ProviderAuth {
|
||||
return {
|
||||
provider: CODEX_SYNTHETIC_USAGE_PROVIDER,
|
||||
token: CODEX_APP_SERVER_AUTH_MARKER,
|
||||
...(params.authProfileId ? { authProfileId: params.authProfileId } : {}),
|
||||
hookProvider: CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseCodexSyntheticUsageForRuntime(params: {
|
||||
provider?: string;
|
||||
effectiveHarness?: string;
|
||||
}): boolean {
|
||||
const harness = normalizeOptionalLowercaseString(params.effectiveHarness);
|
||||
const provider = normalizeOptionalLowercaseString(params.provider);
|
||||
return (
|
||||
harness === CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER &&
|
||||
(provider === CODEX_SYNTHETIC_USAGE_PROVIDER || provider === "codex")
|
||||
);
|
||||
}
|
||||
|
||||
function hasDisplayableUsageSnapshot(snapshot: ProviderUsageSnapshot): boolean {
|
||||
return snapshot.windows.length > 0 || Boolean(snapshot.summary?.trim());
|
||||
}
|
||||
|
||||
function usageSnapshotRank(snapshot: ProviderUsageSnapshot): number {
|
||||
if (hasDisplayableUsageSnapshot(snapshot)) {
|
||||
return 2;
|
||||
}
|
||||
return snapshot.error ? 0 : 1;
|
||||
}
|
||||
|
||||
export function mergeUsageSummaries(
|
||||
base: UsageSummary,
|
||||
extra: UsageSummary | undefined,
|
||||
): UsageSummary {
|
||||
if (!extra || extra.providers.length === 0) {
|
||||
return base;
|
||||
}
|
||||
const providersById = new Map(base.providers.map((provider) => [provider.provider, provider]));
|
||||
for (const provider of extra.providers) {
|
||||
const existing = providersById.get(provider.provider);
|
||||
if (!existing || usageSnapshotRank(provider) >= usageSnapshotRank(existing)) {
|
||||
providersById.set(provider.provider, provider);
|
||||
}
|
||||
}
|
||||
return {
|
||||
updatedAt: base.updatedAt,
|
||||
providers: [...providersById.values()],
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
|
||||
import { resolveContextTokensForModel } from "../agents/context.js";
|
||||
import { resolveFastModeState } from "../agents/fast-mode.js";
|
||||
import { resolveModelAuthLabel } from "../agents/model-auth-label.js";
|
||||
import { CODEX_APP_SERVER_AUTH_MARKER } from "../agents/model-auth-markers.js";
|
||||
import {
|
||||
areRuntimeModelRefsEquivalent,
|
||||
shouldPreferActiveRuntimeAliasAuthLabel,
|
||||
@@ -49,10 +50,6 @@ import {
|
||||
formatTaskStatusTitle,
|
||||
} from "../tasks/task-status.js";
|
||||
import { resolveActiveFallbackState } from "./fallback-notice-state.js";
|
||||
import {
|
||||
buildCodexSyntheticUsageAuth,
|
||||
shouldUseCodexSyntheticUsageForRuntime,
|
||||
} from "./codex-synthetic-usage.js";
|
||||
import { formatCompactPluginHealthLine } from "./status-plugin-health.js";
|
||||
import type { BuildStatusTextParams } from "./status-text.types.js";
|
||||
|
||||
@@ -229,6 +226,15 @@ function resolveCodexSyntheticUsageAuthProfileId(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseCodexSyntheticUsage(params: {
|
||||
provider?: string;
|
||||
effectiveHarness?: string;
|
||||
}): boolean {
|
||||
const harness = normalizeOptionalLowercaseString(params.effectiveHarness);
|
||||
const provider = normalizeOptionalLowercaseString(params.provider);
|
||||
return harness === "codex" && (provider === "openai" || provider === "codex");
|
||||
}
|
||||
|
||||
function formatSessionTaskLine(sessionKey: string): string | undefined {
|
||||
const snapshot = buildTaskStatusSnapshot(listTasksForSessionKeyForStatus(sessionKey));
|
||||
const task = snapshot.focus;
|
||||
@@ -447,11 +453,11 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
const usageProvider = activeRuntimeIsAuthoritative ? activeProvider : selectedLookupProvider;
|
||||
const selectedUsageCredentialType = resolveUsageCredentialType(usageAuthLabel);
|
||||
const useCodexSyntheticUsage =
|
||||
selectedUsageCredentialType !== "api_key" &&
|
||||
shouldUseCodexSyntheticUsageForRuntime({
|
||||
shouldUseCodexSyntheticUsage({
|
||||
provider: usageStatusProvider,
|
||||
effectiveHarness,
|
||||
});
|
||||
}) &&
|
||||
(selectedUsageCredentialType === "oauth" || selectedUsageCredentialType === "token");
|
||||
const codexUsageAuthProfileId = useCodexSyntheticUsage
|
||||
? resolveCodexSyntheticUsageAuthProfileId({
|
||||
profileId: sessionEntry?.authProfileOverride,
|
||||
@@ -485,7 +491,14 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
workspaceDir: statusWorkspaceDir,
|
||||
config: cfg,
|
||||
auth: useCodexSyntheticUsage
|
||||
? [buildCodexSyntheticUsageAuth({ authProfileId: codexUsageAuthProfileId })]
|
||||
? [
|
||||
{
|
||||
provider: "openai",
|
||||
token: CODEX_APP_SERVER_AUTH_MARKER,
|
||||
...(codexUsageAuthProfileId ? { authProfileId: codexUsageAuthProfileId } : {}),
|
||||
hookProvider: "codex",
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
|
||||
@@ -297,7 +297,6 @@ function buildCoreDistEntries(): Record<string, string> {
|
||||
"plugins/runtime/index": "src/plugins/runtime/index.ts",
|
||||
"llm-slug-generator": "src/hooks/llm-slug-generator.ts",
|
||||
"mcp/plugin-tools-serve": "src/mcp/plugin-tools-serve.ts",
|
||||
"mcp/openclaw-tools-serve": "src/mcp/openclaw-tools-serve.ts",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1303,6 +1303,80 @@
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.content--sessions {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(circle at 8% 12%, rgba(255, 0, 128, 0.6), transparent 24%),
|
||||
radial-gradient(circle at 90% 10%, rgba(0, 180, 255, 0.58), transparent 25%),
|
||||
radial-gradient(circle at 82% 88%, rgba(132, 255, 0, 0.48), transparent 26%),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
#ff004c 0%,
|
||||
#ff8a00 15%,
|
||||
#ffe600 30%,
|
||||
#00d084 45%,
|
||||
#00a3ff 60%,
|
||||
#6d4cff 75%,
|
||||
#ff4fd8 90%,
|
||||
#ff004c 100%
|
||||
);
|
||||
background-attachment: local;
|
||||
}
|
||||
|
||||
.content--sessions::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: var(--shell-topbar-height) 0 0 var(--shell-nav-width);
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.24) 0 18px, transparent 18px 36px),
|
||||
conic-gradient(
|
||||
from 0.1turn at 52% 48%,
|
||||
rgba(255, 0, 76, 0.42),
|
||||
rgba(255, 138, 0, 0.38),
|
||||
rgba(255, 230, 0, 0.36),
|
||||
rgba(0, 208, 132, 0.38),
|
||||
rgba(0, 163, 255, 0.42),
|
||||
rgba(109, 76, 255, 0.42),
|
||||
rgba(255, 79, 216, 0.44),
|
||||
rgba(255, 0, 76, 0.42)
|
||||
);
|
||||
filter: saturate(1.45);
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.shell--nav-collapsed .content--sessions::before {
|
||||
left: var(--shell-nav-rail-width);
|
||||
}
|
||||
|
||||
.content--sessions::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: var(--shell-topbar-height) 0 0 var(--shell-nav-width);
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(8, 8, 16, 0.34), rgba(8, 8, 16, 0.66)),
|
||||
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.36), transparent 22%);
|
||||
}
|
||||
|
||||
.shell--nav-collapsed .content--sessions::after {
|
||||
left: var(--shell-nav-rail-width);
|
||||
}
|
||||
|
||||
.content--sessions .content-header,
|
||||
.content--sessions .panel,
|
||||
.content--sessions .card,
|
||||
.content--sessions .settings-workspace {
|
||||
backdrop-filter: blur(14px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.35);
|
||||
box-shadow:
|
||||
0 16px 44px rgba(0, 0, 0, 0.26),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.content--chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -348,6 +348,16 @@ describe("renderApp assistant avatar routing", () => {
|
||||
expect(content?.classList.contains("content--chat")).toBe(false);
|
||||
});
|
||||
|
||||
it("marks the sessions route so it can carry the rainbow background treatment", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(renderApp(createState({ tab: "sessions" })), container);
|
||||
|
||||
const content = container.querySelector<HTMLElement>("main.content");
|
||||
expect(content?.classList.contains("content--sessions")).toBe(true);
|
||||
expect(content?.classList.contains("content--chat")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not render chat errors in non-chat page headers", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
|
||||
@@ -2651,8 +2651,9 @@ export function renderApp(state: AppViewState) {
|
||||
<main
|
||||
class="content ${isChat ? "content--chat" : ""} ${state.tab === "logs"
|
||||
? "content--logs"
|
||||
: ""} ${state.tab === "workboard" ? "content--workboard" : ""} ${state.tab ===
|
||||
"skillWorkshop"
|
||||
: ""} ${state.tab === "sessions" ? "content--sessions" : ""} ${state.tab === "workboard"
|
||||
? "content--workboard"
|
||||
: ""} ${state.tab === "skillWorkshop"
|
||||
? `content--skill-workshop ${
|
||||
state.skillWorkshopMode === "today" ? "content--skill-workshop-today" : ""
|
||||
}`
|
||||
|
||||
Reference in New Issue
Block a user