mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 18:01:53 +08:00
Compare commits
8 Commits
codex/slac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d34c8376 | ||
|
|
fbfadbd806 | ||
|
|
6f1076351c | ||
|
|
898ca9741c | ||
|
|
67118d5ab9 | ||
|
|
bf2a8ecfdb | ||
|
|
cee2aca409 | ||
|
|
56259606d1 |
@@ -316,6 +316,11 @@ conversation bindings, or any non-Codex harness.
|
||||
plugin/app support for the Codex harness. Default: `false`.
|
||||
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions`:
|
||||
default destructive-action policy for migrated plugin app elicitations.
|
||||
Use `true` to accept safe Codex approval schemas without prompting, `false`
|
||||
to decline them, `"auto"` to route Codex-required approvals through OpenClaw
|
||||
plugin approvals, or `"always"` to ask for every plugin write/destructive
|
||||
action without durable approval. The `"always"` mode clears durable Codex
|
||||
per-tool approval overrides for the affected app before starting the thread.
|
||||
Default: `true`.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.enabled`: enables a
|
||||
migrated plugin entry when global `codexPlugins.enabled` is also true.
|
||||
@@ -326,7 +331,8 @@ conversation bindings, or any non-Codex harness.
|
||||
Codex plugin identity from migration, for example `"google-calendar"`.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
|
||||
per-plugin destructive-action override. When omitted, the global
|
||||
`allow_destructive_actions` value is used.
|
||||
`allow_destructive_actions` value is used. The per-plugin value accepts the
|
||||
same `true`, `false`, `"auto"`, or `"always"` policies.
|
||||
|
||||
`codexPlugins.enabled` is the global enablement directive. Explicit plugin
|
||||
entries written by migration are the durable install and repair eligibility set.
|
||||
|
||||
@@ -200,11 +200,11 @@ enabled.
|
||||
|
||||
OpenClaw sets app-level `destructive_enabled` from the effective global or
|
||||
per-plugin `allow_destructive_actions` policy and lets Codex enforce
|
||||
destructive tool metadata from its native app tool annotations. `true` and
|
||||
`"auto"` both set `destructive_enabled: true`; `false` sets it false. The
|
||||
`_default` app config is disabled with `open_world_enabled: false`. Enabled
|
||||
plugin apps are emitted with `open_world_enabled: true`; OpenClaw does not
|
||||
expose a separate plugin open-world policy knob and does not maintain
|
||||
destructive tool metadata from its native app tool annotations. `true`,
|
||||
`"auto"`, and `"always"` set `destructive_enabled: true`; `false` sets it
|
||||
false. The `_default` app config is disabled with `open_world_enabled: false`.
|
||||
Enabled plugin apps are emitted with `open_world_enabled: true`; OpenClaw does
|
||||
not expose a separate plugin open-world policy knob and does not maintain
|
||||
per-plugin destructive tool-name deny lists.
|
||||
|
||||
Tool approval mode is automatic by default for plugin apps so non-destructive
|
||||
@@ -225,6 +225,10 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
|
||||
- When policy is `"auto"`, OpenClaw exposes destructive plugin actions to
|
||||
Codex but turns ownership-proven MCP approval elicitations into OpenClaw
|
||||
plugin approvals before returning the Codex approval response.
|
||||
- When policy is `"always"`, OpenClaw uses the same Codex write/destructive
|
||||
gating as `"auto"`, clears durable Codex per-tool approval overrides for the
|
||||
app before the thread starts, and only offers one-shot approval or denial so
|
||||
durable approvals cannot suppress later write-action prompts.
|
||||
- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn
|
||||
id, or an unsafe elicitation schema declines instead of prompting.
|
||||
|
||||
@@ -272,8 +276,9 @@ Codex thread bindings keep the app config they started with until OpenClaw
|
||||
establishes a new harness session or replaces a stale binding.
|
||||
|
||||
**Destructive action is declined:** check the global and per-plugin
|
||||
`allow_destructive_actions` values. Even when policy is true or `"auto"`,
|
||||
unsafe elicitation schemas and ambiguous plugin identity still fail closed.
|
||||
`allow_destructive_actions` values. Even when policy is true, `"auto"`, or
|
||||
`"always"`, unsafe elicitation schemas and ambiguous plugin identity still fail
|
||||
closed.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -192,6 +192,109 @@ 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),
|
||||
@@ -1163,6 +1266,46 @@ 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,6 +50,7 @@ type OpenClawAcpxRuntimeOptions = AcpRuntimeOptions & {
|
||||
openclawWrapperRoot?: string;
|
||||
openclawGatewayInstanceId?: string;
|
||||
openclawProcessLeaseStore?: AcpxProcessLeaseStore;
|
||||
openclawToolsMcpBridgeEnabled?: boolean;
|
||||
};
|
||||
type AcpxRuntimeTestOptions = Record<string, unknown> & {
|
||||
openclawProcessCleanup?: AcpxProcessCleanupDeps;
|
||||
@@ -57,6 +58,10 @@ 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;
|
||||
@@ -682,6 +687,33 @@ 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;
|
||||
@@ -693,6 +725,10 @@ 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;
|
||||
@@ -706,6 +742,7 @@ 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,
|
||||
@@ -723,20 +760,21 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
sessionStore: this.sessionStore,
|
||||
agentRegistry: this.scopedAgentRegistry,
|
||||
};
|
||||
this.delegate = new BaseAcpxRuntime(
|
||||
sharedOptions,
|
||||
delegateTestOptions as BaseAcpxRuntimeTestOptions,
|
||||
);
|
||||
this.delegateOptions = sharedOptions;
|
||||
this.delegateTestOptions = delegateTestOptions as BaseAcpxRuntimeTestOptions;
|
||||
this.delegate = new BaseAcpxRuntime(sharedOptions, this.delegateTestOptions);
|
||||
this.bridgeSafeDelegate = shouldUseDistinctBridgeDelegate(options)
|
||||
? new BaseAcpxRuntime(
|
||||
{
|
||||
...sharedOptions,
|
||||
mcpServers: [],
|
||||
},
|
||||
delegateTestOptions as BaseAcpxRuntimeTestOptions,
|
||||
this.delegateTestOptions,
|
||||
)
|
||||
: this.delegate;
|
||||
this.probeDelegate = this.resolveDelegateForAgent(resolveProbeAgentName(options));
|
||||
this.probeDelegate = this.openclawToolsMcpBridgeEnabled
|
||||
? this.bridgeSafeDelegate
|
||||
: this.resolveDelegateForAgent(resolveProbeAgentName(options));
|
||||
}
|
||||
|
||||
private resolveDelegateForAgent(agentName: string | undefined): BaseAcpxRuntime {
|
||||
@@ -751,6 +789,57 @@ 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);
|
||||
@@ -762,9 +851,17 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
): BaseAcpxRuntime {
|
||||
const recordCommand = readAgentCommandFromRecord(record);
|
||||
if (recordCommand) {
|
||||
return this.resolveDelegateForCommand(recordCommand);
|
||||
return this.resolveDelegateForSession({
|
||||
command: recordCommand,
|
||||
sessionKey: handle.sessionKey,
|
||||
});
|
||||
}
|
||||
return this.resolveDelegateForAgent(readAgentFromHandle(handle));
|
||||
const agentName = readAgentFromHandle(handle);
|
||||
const command = resolveAgentCommandForName({
|
||||
agentName,
|
||||
agentRegistry: this.agentRegistry,
|
||||
});
|
||||
return this.resolveDelegateForSession({ command, sessionKey: handle.sessionKey });
|
||||
}
|
||||
|
||||
private async resolveCommandForHandle(handle: AcpRuntimeHandle): Promise<string | undefined> {
|
||||
@@ -980,7 +1077,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
agentName: input.agent,
|
||||
agentRegistry: this.agentRegistry,
|
||||
});
|
||||
const delegate = this.resolveDelegateForCommand(command);
|
||||
const delegate = this.resolveDelegateForSession({ command, sessionKey: input.sessionKey });
|
||||
const claudeModelOverride = isClaudeAcpCommand(command)
|
||||
? normalizeClaudeAcpModelOverride(input.model)
|
||||
: undefined;
|
||||
@@ -1264,6 +1361,9 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1272,8 +1372,9 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
);
|
||||
let closeSucceeded;
|
||||
const delegate = this.resolveDelegateForLoadedRecord(input.handle, record);
|
||||
try {
|
||||
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
|
||||
await delegate.close({
|
||||
handle: input.handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
@@ -1282,6 +1383,9 @@ 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,6 +111,7 @@ 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),
|
||||
|
||||
@@ -36,6 +36,14 @@ describe("codex doctor contract", () => {
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
legacyConfigRules[1]?.match({
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": { allow_destructive_actions: "always" },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the retired dynamic tools profile without dropping other Codex config", () => {
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"default": false
|
||||
},
|
||||
"allow_destructive_actions": {
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }],
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }],
|
||||
"default": true
|
||||
},
|
||||
"plugins": {
|
||||
@@ -121,7 +121,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"allow_destructive_actions": {
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }]
|
||||
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,7 +343,7 @@
|
||||
},
|
||||
"codexPlugins.allow_destructive_actions": {
|
||||
"label": "Allow Destructive Plugin Actions",
|
||||
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, or auto to ask through plugin approvals.",
|
||||
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, auto to ask through plugin approvals when Codex requires approval, or always to ask for every write/destructive action without durable approval.",
|
||||
"advanced": true
|
||||
},
|
||||
"codexPlugins.plugins": {
|
||||
|
||||
@@ -346,6 +346,7 @@ export async function startCodexAttemptThread(params: {
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal,
|
||||
}),
|
||||
configCwd: startupExecutionCwd,
|
||||
appCache: defaultCodexAppInventoryCache,
|
||||
appCacheKey: pluginAppCacheKey,
|
||||
}),
|
||||
|
||||
@@ -1192,6 +1192,52 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
});
|
||||
});
|
||||
|
||||
it("parses always native Codex plugin destructive policy", () => {
|
||||
const config = readCodexPluginConfig({
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
slack: {
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "slack",
|
||||
allow_destructive_actions: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.codexPlugins?.allow_destructive_actions).toBe("always");
|
||||
expect(resolveCodexPluginsPolicy(config)).toEqual({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
pluginPolicies: [
|
||||
{
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
{
|
||||
configKey: "slack",
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "slack",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "auto",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported native Codex plugin destructive policy strings", () => {
|
||||
const config = readCodexPluginConfig({
|
||||
codexPlugins: {
|
||||
|
||||
@@ -74,8 +74,8 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange
|
||||
type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
|
||||
type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
|
||||
export type CodexDynamicToolsLoading = "searchable" | "direct";
|
||||
export type CodexPluginDestructivePolicy = boolean | "auto";
|
||||
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto";
|
||||
export type CodexPluginDestructivePolicy = boolean | "auto" | "always";
|
||||
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto" | "always";
|
||||
|
||||
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
|
||||
|
||||
@@ -311,7 +311,11 @@ const codexAppServerApprovalPolicySchema = z.enum([
|
||||
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
|
||||
const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]);
|
||||
const codexPluginDestructivePolicySchema = z.union([z.boolean(), z.literal("auto")]);
|
||||
const codexPluginDestructivePolicySchema = z.union([
|
||||
z.boolean(),
|
||||
z.literal("auto"),
|
||||
z.literal("always"),
|
||||
]);
|
||||
const codexAppServerServiceTierSchema = z
|
||||
.preprocess(
|
||||
(value) => (value === null ? null : normalizeCodexServiceTier(value)),
|
||||
@@ -495,8 +499,8 @@ function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolic
|
||||
allowDestructiveActions: boolean;
|
||||
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
|
||||
} {
|
||||
if (policy === "auto") {
|
||||
return { allowDestructiveActions: true, destructiveApprovalMode: "auto" };
|
||||
if (policy === "auto" || policy === "always") {
|
||||
return { allowDestructiveActions: true, destructiveApprovalMode: policy };
|
||||
}
|
||||
return {
|
||||
allowDestructiveActions: policy,
|
||||
|
||||
@@ -157,7 +157,7 @@ function buildConnectorPluginApprovalElicitation(overrides: Record<string, unkno
|
||||
function createPluginAppPolicyContext(
|
||||
params: {
|
||||
allowDestructiveActions?: boolean;
|
||||
destructiveApprovalMode?: "allow" | "deny" | "auto";
|
||||
destructiveApprovalMode?: "allow" | "deny" | "auto" | "always";
|
||||
apps?: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>;
|
||||
} = {},
|
||||
) {
|
||||
@@ -1017,6 +1017,96 @@ describe("Codex app-server elicitation bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose allow-always for always plugin policy", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-calendar-always-policy", status: "accepted" })
|
||||
.mockResolvedValueOnce({
|
||||
id: "plugin:approval-calendar-always-policy",
|
||||
decision: "allow-once",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerElicitationRequest({
|
||||
requestParams: buildConnectorPluginApprovalElicitation({
|
||||
_meta: {
|
||||
codex_approval_kind: "mcp_tool_call",
|
||||
source: "connector",
|
||||
connector_id: "connector_google_calendar",
|
||||
connector_name: "Google Calendar",
|
||||
persist: ["session", "always"],
|
||||
tool_title: "create_event",
|
||||
},
|
||||
}),
|
||||
paramsForRun: createParams(),
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
pluginAppPolicyContext: createPluginAppPolicyContext({
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
apps: [
|
||||
{
|
||||
appId: "connector_google_calendar",
|
||||
pluginName: "google-calendar",
|
||||
mcpServerNames: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "accept",
|
||||
content: null,
|
||||
_meta: null,
|
||||
});
|
||||
expect(gatewayToolArg(0, 2)).toMatchObject({
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps unexpected allow-always decisions to one-shot for always plugin policy", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({
|
||||
id: "plugin:approval-calendar-unexpected-always",
|
||||
status: "accepted",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: "plugin:approval-calendar-unexpected-always",
|
||||
decision: "allow-always",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerElicitationRequest({
|
||||
requestParams: buildConnectorPluginApprovalElicitation({
|
||||
_meta: {
|
||||
codex_approval_kind: "mcp_tool_call",
|
||||
source: "connector",
|
||||
connector_id: "connector_google_calendar",
|
||||
connector_name: "Google Calendar",
|
||||
persist: ["session", "always"],
|
||||
tool_title: "create_event",
|
||||
},
|
||||
}),
|
||||
paramsForRun: createParams(),
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
pluginAppPolicyContext: createPluginAppPolicyContext({
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
apps: [
|
||||
{
|
||||
appId: "connector_google_calendar",
|
||||
pluginName: "google-calendar",
|
||||
mcpServerNames: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "accept",
|
||||
content: null,
|
||||
_meta: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("declines denied auto plugin app approvals", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-calendar-deny", status: "accepted" })
|
||||
|
||||
@@ -318,10 +318,13 @@ async function buildPluginPolicyElicitationResponse(params: {
|
||||
paramsForRun: params.paramsForRun,
|
||||
title: approvalPrompt.title,
|
||||
description: approvalPrompt.description,
|
||||
allowedDecisions: approvalPrompt.allowedDecisions,
|
||||
allowedDecisions: allowedPluginPolicyApprovalDecisions(mode, approvalPrompt),
|
||||
signal: params.signal,
|
||||
});
|
||||
return buildElicitationResponse(approvalPrompt, outcome);
|
||||
return buildElicitationResponse(
|
||||
approvalPrompt,
|
||||
oneShotPluginPolicyApprovalOutcome(mode, outcome),
|
||||
);
|
||||
}
|
||||
logPluginElicitationDecline("unmappable_schema", params.requestParams);
|
||||
return declineElicitationResponse();
|
||||
@@ -329,10 +332,28 @@ async function buildPluginPolicyElicitationResponse(params: {
|
||||
|
||||
function resolvePluginDestructiveApprovalMode(
|
||||
entry: PluginAppPolicyContextEntry,
|
||||
): "allow" | "deny" | "auto" {
|
||||
): "allow" | "deny" | "auto" | "always" {
|
||||
return entry.destructiveApprovalMode ?? (entry.allowDestructiveActions ? "allow" : "deny");
|
||||
}
|
||||
|
||||
function allowedPluginPolicyApprovalDecisions(
|
||||
mode: "allow" | "deny" | "auto" | "always",
|
||||
approvalPrompt: BridgeableApprovalElicitation,
|
||||
): ExecApprovalDecision[] {
|
||||
const allowedDecisions = approvalPrompt.allowedDecisions ?? ["allow-once", "deny"];
|
||||
if (mode !== "always") {
|
||||
return allowedDecisions;
|
||||
}
|
||||
return allowedDecisions.filter((decision) => decision !== "allow-always");
|
||||
}
|
||||
|
||||
function oneShotPluginPolicyApprovalOutcome(
|
||||
mode: "allow" | "deny" | "auto" | "always",
|
||||
outcome: AppServerApprovalOutcome,
|
||||
): AppServerApprovalOutcome {
|
||||
return mode === "always" && outcome === "approved-session" ? "approved-once" : outcome;
|
||||
}
|
||||
|
||||
function readPluginApprovalElicitation(
|
||||
entry: PluginAppPolicyContextEntry,
|
||||
requestParams: JsonObject,
|
||||
|
||||
@@ -170,6 +170,379 @@ describe("Codex plugin thread config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes destructive app access while clearing only durable approval overrides for always mode", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
let configReadCount = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
configReadCount += 1;
|
||||
if (configReadCount > 1) {
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/read": {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/create": {
|
||||
approval_mode: "approve",
|
||||
enabled: false,
|
||||
},
|
||||
"calendar/read": {
|
||||
enabled: false,
|
||||
},
|
||||
"calendar/update": {
|
||||
approvalMode: "approve",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config/value/write") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
nowMs: 1,
|
||||
request,
|
||||
});
|
||||
|
||||
const apps = config.configPatch?.apps as Record<string, unknown> | undefined;
|
||||
expect(apps?.["google-calendar-app"]).toEqual({
|
||||
enabled: true,
|
||||
destructive_enabled: true,
|
||||
open_world_enabled: true,
|
||||
default_tools_approval_mode: "auto",
|
||||
});
|
||||
expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("config/read", { includeLayers: false });
|
||||
expect(request.mock.calls.filter(([method]) => method === "config/read")).toHaveLength(2);
|
||||
expect(request).toHaveBeenCalledWith("config/value/write", {
|
||||
keyPath: 'apps."google-calendar-app".tools."calendar/create".approval_mode',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("config/value/write", {
|
||||
keyPath: 'apps."google-calendar-app".tools."calendar/update".approval_mode',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
expect(request).not.toHaveBeenCalledWith("config/value/write", {
|
||||
keyPath: 'apps."google-calendar-app".tools',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits always policy apps when cwd effective approval overrides remain after cleanup", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
let configReadCount = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
configReadCount += 1;
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/create": {
|
||||
approval_mode: "approve",
|
||||
source: configReadCount === 1 ? "user" : "project",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config/value/write") {
|
||||
return { status: "ok" };
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
configCwd: "/repo/project",
|
||||
nowMs: 1,
|
||||
request,
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(request).toHaveBeenCalledWith("config/read", {
|
||||
includeLayers: false,
|
||||
cwd: "/repo/project",
|
||||
});
|
||||
expect(request.mock.calls.filter(([method]) => method === "config/read")).toHaveLength(2);
|
||||
expect(config.diagnostics).toStrictEqual([
|
||||
{
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
message:
|
||||
"Could not clear durable Codex app approval overrides for google-calendar-app: effective approval overrides remain for calendar/create",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits always policy apps when approval override writes are overridden", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
return {
|
||||
config: {
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
tools: {
|
||||
"calendar/create": {
|
||||
approval_mode: "approve",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config/value/write") {
|
||||
return { status: "okOverridden" };
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
configCwd: "/repo/project",
|
||||
nowMs: 1,
|
||||
request,
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(config.diagnostics).toStrictEqual([
|
||||
{
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
message:
|
||||
"Could not clear durable Codex app approval overrides for google-calendar-app: approval override for calendar/create is controlled by another config layer",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits always policy apps when durable approval override cleanup fails", async () => {
|
||||
const appCache = new CodexAppInventoryCache();
|
||||
await appCache.refreshNow({
|
||||
key: "runtime",
|
||||
nowMs: 0,
|
||||
request: async () => ({
|
||||
data: [appInfo("google-calendar-app", true)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const config = await buildCodexPluginThreadConfig({
|
||||
pluginConfig: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appCache,
|
||||
appCacheKey: "runtime",
|
||||
nowMs: 1,
|
||||
request: async (method) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginDetail(
|
||||
"google-calendar",
|
||||
[appSummary("google-calendar-app")],
|
||||
["google-calendar"],
|
||||
);
|
||||
}
|
||||
if (method === "config/read") {
|
||||
throw new Error("readonly config");
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.configPatch).toEqual({
|
||||
apps: {
|
||||
_default: {
|
||||
enabled: false,
|
||||
destructive_enabled: false,
|
||||
open_world_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.policyContext.apps).toStrictEqual({});
|
||||
expect(config.diagnostics).toStrictEqual([
|
||||
{
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always",
|
||||
},
|
||||
message:
|
||||
"Could not clear durable Codex app approval overrides for google-calendar-app: readonly config",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds a restrictive app config when native plugin support is disabled", async () => {
|
||||
expect(
|
||||
shouldBuildCodexPluginThreadConfig({
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type CodexPluginOwnedApp,
|
||||
type CodexPluginRuntimeRequest,
|
||||
} from "./plugin-inventory.js";
|
||||
import type { JsonObject, JsonValue } from "./protocol.js";
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
||||
|
||||
/** Policy context for one app id exposed by a configured Codex plugin. */
|
||||
export type PluginAppPolicyContextEntry = {
|
||||
@@ -52,7 +52,7 @@ export type PluginAppPolicyContext = {
|
||||
export type CodexPluginThreadConfigDiagnostic =
|
||||
| CodexPluginInventoryDiagnostic
|
||||
| {
|
||||
code: "plugin_activation_failed" | "app_not_ready";
|
||||
code: "plugin_activation_failed" | "app_not_ready" | "approval_overrides_clear_failed";
|
||||
plugin?: ResolvedCodexPluginPolicy;
|
||||
message: string;
|
||||
};
|
||||
@@ -72,6 +72,7 @@ export type CodexPluginThreadConfig = {
|
||||
export type BuildCodexPluginThreadConfigParams = {
|
||||
pluginConfig?: unknown;
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
appCache?: CodexAppInventoryCache;
|
||||
appCacheKey: string;
|
||||
nowMs?: number;
|
||||
@@ -250,6 +251,18 @@ export async function buildCodexPluginThreadConfig(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
record.policy.destructiveApprovalMode === "always" &&
|
||||
!(await clearPersistedAppToolApprovalOverrides({
|
||||
request: params.request,
|
||||
configCwd: params.configCwd,
|
||||
plugin: record.policy,
|
||||
app,
|
||||
diagnostics,
|
||||
}))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const appConfig: JsonObject = {
|
||||
enabled: true,
|
||||
destructive_enabled: record.policy.allowDestructiveActions,
|
||||
@@ -367,6 +380,86 @@ function buildPluginAppPolicyContext(
|
||||
};
|
||||
}
|
||||
|
||||
async function clearPersistedAppToolApprovalOverrides(params: {
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
plugin: ResolvedCodexPluginPolicy;
|
||||
app: CodexPluginOwnedApp;
|
||||
diagnostics: CodexPluginThreadConfigDiagnostic[];
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const overrideNames = await readPersistedAppToolApprovalOverrideNames(params);
|
||||
for (const toolName of overrideNames) {
|
||||
const response = await params.request("config/value/write", {
|
||||
keyPath: `apps.${quoteConfigKeyPathSegment(params.app.id)}.tools.${quoteConfigKeyPathSegment(
|
||||
toolName,
|
||||
)}.approval_mode`,
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
});
|
||||
if (isOverriddenConfigWriteResponse(response)) {
|
||||
throw new Error(`approval override for ${toolName} is controlled by another config layer`);
|
||||
}
|
||||
}
|
||||
const remainingOverrideNames = await readPersistedAppToolApprovalOverrideNames(params);
|
||||
if (remainingOverrideNames.length > 0) {
|
||||
throw new Error(
|
||||
`effective approval overrides remain for ${remainingOverrideNames.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
params.diagnostics.push({
|
||||
code: "approval_overrides_clear_failed",
|
||||
plugin: params.plugin,
|
||||
message: `Could not clear durable Codex app approval overrides for ${params.app.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readPersistedAppToolApprovalOverrideNames(params: {
|
||||
request: CodexPluginRuntimeRequest;
|
||||
configCwd?: string;
|
||||
app: CodexPluginOwnedApp;
|
||||
}): Promise<string[]> {
|
||||
const response = await params.request("config/read", {
|
||||
includeLayers: false,
|
||||
...(params.configCwd ? { cwd: params.configCwd } : {}),
|
||||
});
|
||||
const config = isJsonObject(response) ? response.config : undefined;
|
||||
const appsRoot = isJsonObject(config) ? config.apps : undefined;
|
||||
const nestedApps = isJsonObject(appsRoot) ? appsRoot.apps : undefined;
|
||||
const appConfig = isJsonObject(appsRoot)
|
||||
? (appsRoot[params.app.id] ??
|
||||
(isJsonObject(nestedApps) ? nestedApps[params.app.id] : undefined))
|
||||
: undefined;
|
||||
const tools = isJsonObject(appConfig) ? appConfig.tools : undefined;
|
||||
if (!isJsonObject(tools)) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(tools)
|
||||
.filter(([, value]) => hasPersistedToolApprovalOverride(value))
|
||||
.map(([toolName]) => toolName)
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
function hasPersistedToolApprovalOverride(value: JsonValue): boolean {
|
||||
return (
|
||||
isJsonObject(value) && (value.approval_mode !== undefined || value.approvalMode !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function isOverriddenConfigWriteResponse(response: unknown): boolean {
|
||||
return isJsonObject(response) && response.status === "okOverridden";
|
||||
}
|
||||
|
||||
function quoteConfigKeyPathSegment(segment: string): string {
|
||||
return `"${segment.replace(/["\\]/g, (char) => `\\${char}`)}"`;
|
||||
}
|
||||
|
||||
function shouldWaitForInitialAppInventory(
|
||||
params: BuildCodexPluginThreadConfigParams,
|
||||
policy: ResolvedCodexPluginsPolicy,
|
||||
|
||||
@@ -575,6 +575,8 @@ type CodexAppServerRequestResultMap = {
|
||||
"account/read": CodexGetAccountResponse;
|
||||
"app/list": CodexAppsListResponse;
|
||||
"config/mcpServer/reload": JsonValue;
|
||||
"config/read": JsonValue;
|
||||
"config/value/write": JsonValue;
|
||||
"environment/add": JsonValue;
|
||||
"experimentalFeature/enablement/set": JsonValue;
|
||||
"feedback/upload": JsonValue;
|
||||
|
||||
@@ -112,6 +112,44 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("allows config value writes in sandboxed sessions", async () => {
|
||||
const request = vi.fn(async () => ({ ok: true }));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
const params = {
|
||||
keyPath: 'apps."google-calendar-app".tools',
|
||||
value: null,
|
||||
mergeStrategy: "replace",
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "config/value/write",
|
||||
requestParams: params,
|
||||
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
|
||||
sessionKey: "sandboxed-session",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config/value/write", params, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("allows config reads in sandboxed sessions", async () => {
|
||||
const request = vi.fn(async () => ({ config: { apps: { apps: {} } } }));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
const params = { includeLayers: false };
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "config/read",
|
||||
requestParams: params,
|
||||
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
|
||||
sessionKey: "sandboxed-session",
|
||||
}),
|
||||
).resolves.toEqual({ config: { apps: { apps: {} } } });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config/read", params, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("allows sandbox-pinned thread starts in sandboxed sessions", async () => {
|
||||
const request = vi.fn(async () => ({ thread: { id: "thread-1" }, model: "gpt-5.5" }));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
|
||||
@@ -19,6 +19,8 @@ const DIRECT_METHOD_POLICIES = new Map<string, DirectMethodPolicy>([
|
||||
["account/read", "allowed-control-plane"],
|
||||
["app/list", "allowed-control-plane"],
|
||||
["config/mcpServer/reload", "allowed-control-plane"],
|
||||
["config/read", "allowed-control-plane"],
|
||||
["config/value/write", "allowed-control-plane"],
|
||||
["environment/add", "allowed-control-plane"],
|
||||
["experimentalFeature/enablement/set", "allowed-control-plane"],
|
||||
["feedback/upload", "allowed-control-plane"],
|
||||
|
||||
@@ -145,6 +145,35 @@ describe("codex app-server session binding", () => {
|
||||
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
|
||||
});
|
||||
|
||||
it("round-trips always plugin app policy context destructive approval mode", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.json");
|
||||
const pluginAppPolicyContext = {
|
||||
fingerprint: "plugin-policy-always",
|
||||
apps: {
|
||||
"google-calendar-app": {
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: "openai-curated" as const,
|
||||
pluginName: "google-calendar",
|
||||
allowDestructiveActions: true,
|
||||
destructiveApprovalMode: "always" as const,
|
||||
mcpServerNames: ["google-calendar"],
|
||||
},
|
||||
},
|
||||
pluginAppIds: {
|
||||
"google-calendar": ["google-calendar-app"],
|
||||
},
|
||||
};
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-123",
|
||||
cwd: tempDir,
|
||||
pluginAppPolicyContext,
|
||||
});
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
|
||||
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
|
||||
});
|
||||
|
||||
it("normalizes v1 plugin app policy context destructive approval modes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.json");
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -421,6 +421,9 @@ function readDestructiveApprovalMode(
|
||||
if (value === "auto") {
|
||||
return bindingSchemaVersion === 1 ? "allow" : "auto";
|
||||
}
|
||||
if (value === "always" && bindingSchemaVersion === 2) {
|
||||
return "always";
|
||||
}
|
||||
if (value === "on-request" && bindingSchemaVersion === 1) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createCodexTrajectoryRecorder,
|
||||
recordCodexTrajectoryCompletion,
|
||||
recordCodexTrajectoryContext,
|
||||
resolveCodexTrajectoryAppendFlags,
|
||||
resolveCodexTrajectoryPointerFlags,
|
||||
@@ -80,7 +81,9 @@ describe("Codex trajectory recorder", () => {
|
||||
expect(content).not.toContain("secret");
|
||||
expect(content).not.toContain("sk-test-secret-token");
|
||||
expect(content).not.toContain("sk-other-secret-token");
|
||||
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
|
||||
if (process.platform !== "win32") {
|
||||
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
expect(fs.existsSync(path.join(tmpDir, "session.trajectory-path.json"))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -253,4 +256,235 @@ describe("Codex trajectory recorder", () => {
|
||||
expect(parsed.data?.truncated).toBe(true);
|
||||
expect(parsed.data?.reason).toBe("trajectory-event-size-limit");
|
||||
});
|
||||
|
||||
it("preserves usage when truncating oversized model completion events", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const usage = {
|
||||
input: 384_954,
|
||||
output: 5_624,
|
||||
cacheRead: 333_824,
|
||||
reasoningTokens: 2_038,
|
||||
total: 724_402,
|
||||
};
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: usage,
|
||||
assistantTexts: ["done"],
|
||||
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index} ${"x".repeat(32_000)}`,
|
||||
})),
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
|
||||
);
|
||||
expect(parsed.type).toBe("model.completed");
|
||||
expect(parsed.data).toMatchObject({
|
||||
truncated: true,
|
||||
reason: "trajectory-event-size-limit",
|
||||
usage,
|
||||
});
|
||||
expect(parsed.data.messagesSnapshot).toBeUndefined();
|
||||
expect(parsed.data.droppedFields).toContain("messagesSnapshot");
|
||||
expect(Buffer.byteLength(JSON.stringify(parsed), "utf8")).toBeLessThanOrEqual(256 * 1024);
|
||||
});
|
||||
|
||||
it("drops oversized preserved fields when needed to keep completion events bounded", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const oversizedUsage = Object.fromEntries(
|
||||
Array.from({ length: 100 }, (_value, index) => [`field-${index}`, "x".repeat(5_000)]),
|
||||
);
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: oversizedUsage,
|
||||
assistantTexts: ["x".repeat(32_000)],
|
||||
messagesSnapshot: [{ role: "assistant", content: "x".repeat(32_000) }],
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
|
||||
);
|
||||
expect(parsed.data).toMatchObject({
|
||||
truncated: true,
|
||||
reason: "trajectory-event-size-limit",
|
||||
});
|
||||
expect(parsed.data.usage).toBeUndefined();
|
||||
expect(parsed.data.droppedFields).toEqual(
|
||||
expect.arrayContaining(["usage", "assistantTexts", "messagesSnapshot"]),
|
||||
);
|
||||
expect(Buffer.byteLength(JSON.stringify(parsed), "utf8")).toBeLessThanOrEqual(256 * 1024);
|
||||
});
|
||||
|
||||
it("preserves usage on non-final oversized model completion events", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const firstUsage = {
|
||||
input: 384_954,
|
||||
output: 5_624,
|
||||
cacheRead: 333_824,
|
||||
reasoningTokens: 2_038,
|
||||
total: 724_402,
|
||||
};
|
||||
const secondUsage = { input: 12, output: 3, total: 15 };
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: firstUsage,
|
||||
assistantTexts: ["first"],
|
||||
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index} ${"x".repeat(32_000)}`,
|
||||
})),
|
||||
} as never,
|
||||
});
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-2",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: secondUsage,
|
||||
assistantTexts: ["final answer"],
|
||||
messagesSnapshot: [{ role: "assistant", content: "final answer" }],
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const events = fs
|
||||
.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8")
|
||||
.trim()
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].data).toMatchObject({
|
||||
truncated: true,
|
||||
usage: firstUsage,
|
||||
});
|
||||
expect(events[1].data).toMatchObject({
|
||||
turnId: "turn-2",
|
||||
usage: secondUsage,
|
||||
assistantTexts: ["final answer"],
|
||||
});
|
||||
expect(events[1].data.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts secrets before preserving usage in truncated completion events", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const attempt = {
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
model: { api: "responses" },
|
||||
} as never;
|
||||
const recorder = createCodexTrajectoryRecorder({
|
||||
cwd: tmpDir,
|
||||
attempt,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
timedOut: false,
|
||||
result: {
|
||||
aborted: false,
|
||||
attemptUsage: {
|
||||
total: 1,
|
||||
apiKey: "sk-test-secret-token",
|
||||
authorization: "Bearer sk-other-secret-token",
|
||||
},
|
||||
assistantTexts: ["done"],
|
||||
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index} ${"x".repeat(32_000)}`,
|
||||
})),
|
||||
} as never,
|
||||
});
|
||||
await trajectoryRecorder.flush();
|
||||
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
|
||||
);
|
||||
const preservedUsage = JSON.stringify(parsed.data.usage);
|
||||
expect(parsed.data.truncated).toBe(true);
|
||||
expect(preservedUsage).toContain("redacted");
|
||||
expect(preservedUsage).not.toContain("sk-test-secret-token");
|
||||
expect(preservedUsage).not.toContain("sk-other-secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ const JWT_VALUE_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]
|
||||
const COOKIE_PAIR_RE = /\b([A-Za-z][A-Za-z0-9_.-]{1,64})=([A-Za-z0-9+/._~%=-]{16,})(?=;|\s|$)/gu;
|
||||
const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024;
|
||||
const TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS = ["usage", "promptCache"] as const;
|
||||
|
||||
type CodexTrajectoryOpenFlagConstants = Pick<
|
||||
typeof nodeFs.constants,
|
||||
@@ -82,19 +83,57 @@ function boundedTrajectoryLine(event: Record<string, unknown>): string | undefin
|
||||
if (bytes <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
|
||||
return `${line}\n`;
|
||||
}
|
||||
const truncated = JSON.stringify({
|
||||
...event,
|
||||
data: {
|
||||
truncated: true,
|
||||
originalBytes: bytes,
|
||||
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
|
||||
reason: "trajectory-event-size-limit",
|
||||
},
|
||||
});
|
||||
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
|
||||
return `${truncated}\n`;
|
||||
|
||||
const originalData =
|
||||
event.data && typeof event.data === "object" && !Array.isArray(event.data)
|
||||
? (event.data as Record<string, unknown>)
|
||||
: {};
|
||||
const originalDataKeys = Object.keys(originalData);
|
||||
const preservedDataKeys = new Set<string>();
|
||||
const baseData = {
|
||||
truncated: true,
|
||||
originalBytes: bytes,
|
||||
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
|
||||
reason: "trajectory-event-size-limit",
|
||||
};
|
||||
const buildTruncatedLine = (includeDroppedFields: boolean): string | undefined => {
|
||||
const data: Record<string, unknown> = { ...baseData };
|
||||
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
|
||||
if (preservedDataKeys.has(key)) {
|
||||
data[key] = originalData[key];
|
||||
}
|
||||
}
|
||||
if (includeDroppedFields) {
|
||||
const droppedFields = originalDataKeys.filter((key) => !preservedDataKeys.has(key));
|
||||
if (droppedFields.length > 0) {
|
||||
data.droppedFields = droppedFields;
|
||||
}
|
||||
}
|
||||
const truncated = JSON.stringify({ ...event, data });
|
||||
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
|
||||
return `${truncated}\n`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let best = buildTruncatedLine(true) ?? buildTruncatedLine(false);
|
||||
if (!best) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
|
||||
if (!Object.hasOwn(originalData, key)) {
|
||||
continue;
|
||||
}
|
||||
preservedDataKeys.add(key);
|
||||
const next = buildTruncatedLine(true) ?? buildTruncatedLine(false);
|
||||
if (next) {
|
||||
best = next;
|
||||
continue;
|
||||
}
|
||||
preservedDataKeys.delete(key);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function resolveTrajectoryPointerFilePath(sessionFile: string): string {
|
||||
|
||||
@@ -23,7 +23,7 @@ export type CodexPluginConfigEntry = {
|
||||
enabled?: boolean;
|
||||
marketplaceName?: string;
|
||||
pluginName?: string;
|
||||
allow_destructive_actions?: boolean | "auto";
|
||||
allow_destructive_actions?: boolean | "auto" | "always";
|
||||
};
|
||||
|
||||
export type CodexPluginsConfigBlock = {
|
||||
|
||||
@@ -43,7 +43,7 @@ export type CodexPluginMigrationConfigEntry = {
|
||||
configKey: string;
|
||||
pluginName: string;
|
||||
enabled: boolean;
|
||||
allowDestructiveActions?: "auto";
|
||||
allowDestructiveActions?: "auto" | "always";
|
||||
};
|
||||
|
||||
type CodexPluginMigrationBlockSkipDetails = {
|
||||
@@ -168,15 +168,18 @@ function isLegacyDestructivePolicyRepair(
|
||||
);
|
||||
}
|
||||
|
||||
function isLegacyDestructivePolicyConfigEntryRepair(
|
||||
function readExistingPluginAllowDestructiveActions(
|
||||
existing: unknown,
|
||||
pluginName: string,
|
||||
): boolean {
|
||||
): "auto" | "always" | undefined {
|
||||
const existingEntry = isRecord(existing) ? existing : undefined;
|
||||
return (
|
||||
existingEntry?.allow_destructive_actions === "on-request" &&
|
||||
existingEntry.pluginName === pluginName
|
||||
if (existingEntry?.pluginName !== pluginName) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeExistingAllowDestructiveActions(
|
||||
existingEntry.allow_destructive_actions,
|
||||
);
|
||||
return normalized === "auto" || normalized === "always" ? normalized : undefined;
|
||||
}
|
||||
|
||||
function buildPluginItems(
|
||||
@@ -203,12 +206,15 @@ function buildPluginItems(
|
||||
enabled: true,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: plugin.pluginName,
|
||||
...(isLegacyDestructivePolicyConfigEntryRepair(
|
||||
existingPluginEntries[configKey],
|
||||
plugin.pluginName,
|
||||
)
|
||||
? { allow_destructive_actions: "auto" }
|
||||
: {}),
|
||||
...(() => {
|
||||
const allowDestructiveActions = readExistingPluginAllowDestructiveActions(
|
||||
existingPluginEntries[configKey],
|
||||
plugin.pluginName,
|
||||
);
|
||||
return allowDestructiveActions
|
||||
? { allow_destructive_actions: allowDestructiveActions }
|
||||
: {};
|
||||
})(),
|
||||
};
|
||||
const conflict =
|
||||
!ctx.overwrite &&
|
||||
@@ -234,8 +240,9 @@ function buildPluginItems(
|
||||
pluginName: plugin.pluginName,
|
||||
sourceInstalled: plugin.installed === true,
|
||||
sourceEnabled: plugin.enabled === true,
|
||||
...(plannedEntry.allow_destructive_actions === "auto"
|
||||
? { allowDestructiveActions: "auto" }
|
||||
...(plannedEntry.allow_destructive_actions === "auto" ||
|
||||
plannedEntry.allow_destructive_actions === "always"
|
||||
? { allowDestructiveActions: plannedEntry.allow_destructive_actions }
|
||||
: {}),
|
||||
...(plugin.apps && plugin.apps.length > 0 && !shouldVerifyPluginApps(ctx)
|
||||
? { sourceAppVerification: CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED }
|
||||
@@ -310,13 +317,15 @@ export function readCodexPluginMigrationConfigEntry(
|
||||
configKey,
|
||||
pluginName,
|
||||
enabled,
|
||||
...(allowDestructiveActions === "auto" ? { allowDestructiveActions: "auto" } : {}),
|
||||
...(allowDestructiveActions === "auto" || allowDestructiveActions === "always"
|
||||
? { allowDestructiveActions }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readExistingAllowDestructiveActions(
|
||||
config: MigrationProviderContext["config"],
|
||||
): boolean | "auto" | undefined {
|
||||
): boolean | "auto" | "always" | undefined {
|
||||
const value = readMigrationConfigPath(config as Record<string, unknown>, [
|
||||
...CODEX_PLUGIN_NATIVE_CONFIG_PATH,
|
||||
"allow_destructive_actions",
|
||||
@@ -324,8 +333,16 @@ function readExistingAllowDestructiveActions(
|
||||
return normalizeExistingAllowDestructiveActions(value);
|
||||
}
|
||||
|
||||
function normalizeExistingAllowDestructiveActions(value: unknown): boolean | "auto" | undefined {
|
||||
return value === "auto" || value === "on-request" ? "auto" : asBoolean(value);
|
||||
function normalizeExistingAllowDestructiveActions(
|
||||
value: unknown,
|
||||
): boolean | "auto" | "always" | undefined {
|
||||
if (value === "auto" || value === "on-request") {
|
||||
return "auto";
|
||||
}
|
||||
if (value === "always") {
|
||||
return "always";
|
||||
}
|
||||
return asBoolean(value);
|
||||
}
|
||||
|
||||
function readExistingPluginPolicyRepairs(
|
||||
|
||||
@@ -2108,6 +2108,76 @@ describe("buildCodexMigrationProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves global always destructive plugin policy during migration", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const configState: MigrationProviderContext["config"] = {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults: { workspace: fixture.workspaceDir } },
|
||||
} as MigrationProviderContext["config"];
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
if (method === "skills/list") {
|
||||
return { data: [] } satisfies v2.SkillsListResponse;
|
||||
}
|
||||
if (method === "hooks/list") {
|
||||
return { data: [] } satisfies v2.HooksListResponse;
|
||||
}
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
runtime: createConfigRuntime(configState),
|
||||
});
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
config: configState,
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "migrated" });
|
||||
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
|
||||
enabled: true,
|
||||
allow_destructive_actions: "always",
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
enabled: true,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("records auth-required plugin installs as disabled explicit config entries", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const configState: MigrationProviderContext["config"] = {
|
||||
|
||||
@@ -476,6 +476,45 @@ describe("google transport stream", () => {
|
||||
expect(result.content[2]).toHaveProperty("thoughtSignature", "Y2FsbF9zaWdfMQ==");
|
||||
});
|
||||
|
||||
it("preserves MAX_TOKENS when the partial response contains a function call", async () => {
|
||||
guardedFetchMock.mockResolvedValueOnce(
|
||||
buildSseResponse([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ functionCall: { name: "lookup", args: { q: "hello" } } }],
|
||||
},
|
||||
finishReason: "MAX_TOKENS",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const streamFn = createGoogleGenerativeAiTransportStreamFn();
|
||||
const stream = await Promise.resolve(
|
||||
streamFn(
|
||||
buildGeminiModel(),
|
||||
{
|
||||
messages: [{ role: "user", content: "hello", timestamp: 0 }],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Look up a value",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof streamFn>[1],
|
||||
{ apiKey: "gemini-api-key" } as Parameters<typeof streamFn>[2],
|
||||
),
|
||||
);
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("length");
|
||||
expect(result.content).toEqual([expect.objectContaining({ type: "toolCall", name: "lookup" })]);
|
||||
});
|
||||
|
||||
it("strips redundant google provider prefixes from Gemini API model paths", async () => {
|
||||
guardedFetchMock.mockResolvedValueOnce(buildSseResponse([]));
|
||||
|
||||
|
||||
@@ -1404,7 +1404,12 @@ function createGoogleTransportStreamFn(kind: CanonicalGoogleTransportApi): Strea
|
||||
}
|
||||
if (typeof candidate?.finishReason === "string") {
|
||||
output.stopReason = mapStopReasonString(candidate.finishReason);
|
||||
if (output.content.some((block) => block.type === "toolCall")) {
|
||||
// MAX_TOKENS can leave a complete-looking partial call. Only a normal
|
||||
// Google stop may promote parsed calls into an executable tool-use turn.
|
||||
if (
|
||||
output.stopReason === "stop" &&
|
||||
output.content.some((block) => block.type === "toolCall")
|
||||
) {
|
||||
output.stopReason = "toolUse";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,13 +98,13 @@ describe("buildAssistantMessage", () => {
|
||||
expect(msg.stopReason).toBe("length");
|
||||
});
|
||||
|
||||
it("keeps tool use authoritative over a length stop", () => {
|
||||
it("keeps a length stop authoritative over complete-looking tool calls", () => {
|
||||
const response = makeOllamaResponse({
|
||||
done_reason: "length",
|
||||
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.stopReason).toBe("toolUse");
|
||||
expect(msg.stopReason).toBe("length");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,6 +282,32 @@ describe("createOllamaStreamFn thinking events", () => {
|
||||
expect(done.message?.stopReason).toBe("length");
|
||||
});
|
||||
|
||||
it("preserves a native length stop when the partial response contains tool calls", async () => {
|
||||
const events = await streamOllamaEvents(
|
||||
[
|
||||
makeOllamaResponse({
|
||||
done_reason: "length",
|
||||
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
|
||||
}),
|
||||
],
|
||||
{},
|
||||
{
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
tools: [{ name: "read", description: "Read files", parameters: { type: "object" } }],
|
||||
} as never,
|
||||
);
|
||||
|
||||
const done = events.find((event) => event.type === "done") as {
|
||||
reason?: string;
|
||||
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
|
||||
};
|
||||
expect(done.reason).toBe("length");
|
||||
expect(done.message?.stopReason).toBe("length");
|
||||
expect(done.message?.content).toEqual([
|
||||
expect.objectContaining({ type: "toolCall", name: "read" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses generic stream timeout for Ollama request timeout", async () => {
|
||||
await streamOllamaEvents([makeOllamaResponse({ content: "ok" })], { timeoutMs: 2500 });
|
||||
|
||||
|
||||
@@ -656,10 +656,15 @@ function estimateTokensFromChars(chars: number): number {
|
||||
}
|
||||
|
||||
function resolveOllamaStopReason(response: OllamaChatResponse) {
|
||||
// Ollama's length terminal means generation hit its token limit, even when
|
||||
// the partial response already contains a complete-looking tool call.
|
||||
if (response.done_reason === "length") {
|
||||
return "length" as const;
|
||||
}
|
||||
if (response.message.tool_calls?.length) {
|
||||
return "toolUse" as const;
|
||||
}
|
||||
return response.done_reason === "length" ? ("length" as const) : ("stop" as const);
|
||||
return "stop" as const;
|
||||
}
|
||||
|
||||
function estimateOllamaPromptTokens(params: {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { z } from "zod";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { validateSlackBlocksArray } from "./blocks-input.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient, getSlackWriteClient } from "./client.js";
|
||||
import { buildSlackEditTextPayload } from "./edit-text.js";
|
||||
import { resolveSlackMedia } from "./monitor/media.js";
|
||||
@@ -72,22 +71,6 @@ function resolveToken(explicit?: string, accountId?: string, cfg?: OpenClawConfi
|
||||
return token;
|
||||
}
|
||||
|
||||
function resolveSlackActionClientOptions(opts: SlackActionClientOpts) {
|
||||
if (!opts.cfg) {
|
||||
return createSlackApiUrlClientOptions();
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Slack actions");
|
||||
resolveSlackAccount({ cfg, accountId: opts.accountId });
|
||||
return createSlackApiUrlClientOptions();
|
||||
}
|
||||
|
||||
function slackActionClientOptionArgs(
|
||||
opts: SlackActionClientOpts,
|
||||
): [] | [ReturnType<typeof createSlackApiUrlClientOptions>] {
|
||||
const clientOptions = resolveSlackActionClientOptions(opts);
|
||||
return clientOptions.slackApiUrl ? [clientOptions] : [];
|
||||
}
|
||||
|
||||
function normalizeEmoji(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
@@ -148,10 +131,7 @@ async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write
|
||||
return opts.client;
|
||||
}
|
||||
const token = resolveToken(opts.token, opts.accountId, opts.cfg);
|
||||
const clientOptionArgs = slackActionClientOptionArgs(opts);
|
||||
return mode === "write"
|
||||
? getSlackWriteClient(token, ...clientOptionArgs)
|
||||
: createSlackWebClient(token, ...clientOptionArgs);
|
||||
return mode === "write" ? getSlackWriteClient(token) : createSlackWebClient(token);
|
||||
}
|
||||
|
||||
async function resolveBotUserId(client: WebClient) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { normalizeAllowListLower } from "./monitor/allow-list.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
@@ -77,7 +76,7 @@ export async function resolveSlackConversationInfo(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createSlackWebClient(token, createSlackApiUrlClientOptions());
|
||||
const client = createSlackWebClient(token);
|
||||
if (isNativeImChannel) {
|
||||
const opened = await client.conversations.open({
|
||||
channel: channelId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Slack plugin module implements channel behavior.
|
||||
import {
|
||||
buildLegacyDmAccountAllowlistAdapter,
|
||||
createAccountScopedAllowlistNameResolver,
|
||||
createFlatAllowlistOverrideResolver,
|
||||
} from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
@@ -51,7 +52,6 @@ import {
|
||||
type OpenClawConfig,
|
||||
} from "./channel-api.js";
|
||||
import { resolveSlackChannelType, resolveSlackConversationInfo } from "./channel-type.js";
|
||||
import { createSlackApiUrlClientOptions, type SlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { shouldSuppressLocalSlackExecApprovalPrompt } from "./exec-approvals.js";
|
||||
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
|
||||
import {
|
||||
@@ -405,40 +405,19 @@ function formatSlackScopeDiagnostic(params: {
|
||||
} as const;
|
||||
}
|
||||
|
||||
function slackApiUrlOptionArgs(): [] | [SlackApiUrlClientOptions] {
|
||||
const options = createSlackApiUrlClientOptions();
|
||||
return options.slackApiUrl ? [options] : [];
|
||||
}
|
||||
|
||||
const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver({
|
||||
resolveRecord: (account: ResolvedSlackAccount) => account.channels,
|
||||
label: (key) => key,
|
||||
resolveEntries: (value) => value?.users,
|
||||
});
|
||||
|
||||
const resolveSlackAllowlistNames = async ({
|
||||
accountId,
|
||||
cfg,
|
||||
entries,
|
||||
}: {
|
||||
accountId?: string | null;
|
||||
cfg: OpenClawConfig;
|
||||
entries: string[];
|
||||
}) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const token =
|
||||
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken);
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
return await (
|
||||
await loadSlackResolveUsersModule()
|
||||
).resolveSlackUserAllowlist({
|
||||
token,
|
||||
entries,
|
||||
...createSlackApiUrlClientOptions(),
|
||||
});
|
||||
};
|
||||
const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
|
||||
resolveAccount: resolveSlackAccount,
|
||||
resolveToken: (account: ResolvedSlackAccount) =>
|
||||
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
|
||||
resolveNames: async ({ token, entries }) =>
|
||||
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
|
||||
});
|
||||
|
||||
const slackChannelOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
@@ -675,7 +654,6 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
(await loadSlackResolveChannelsModule()).resolveSlackChannelAllowlist({
|
||||
token,
|
||||
entries: inputsValue,
|
||||
...createSlackApiUrlClientOptions(),
|
||||
}),
|
||||
mapResolved: (entry) =>
|
||||
toResolvedTarget(entry, entry.archived ? "archived" : undefined),
|
||||
@@ -683,14 +661,14 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
}
|
||||
return resolveTargetsWithOptionalToken({
|
||||
token:
|
||||
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
|
||||
normalizeOptionalString(account.userToken) ??
|
||||
normalizeOptionalString(account.botToken),
|
||||
inputs,
|
||||
missingTokenNote: "missing Slack token",
|
||||
resolveWithToken: async ({ token, inputs: inputsLocal }) =>
|
||||
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({
|
||||
token,
|
||||
entries: inputsLocal,
|
||||
...createSlackApiUrlClientOptions(),
|
||||
}),
|
||||
mapResolved: (entry) => toResolvedTarget(entry, entry.note),
|
||||
});
|
||||
@@ -717,9 +695,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
if (!token) {
|
||||
return { ok: false, error: "missing token" };
|
||||
}
|
||||
return await (
|
||||
await loadSlackProbeModule()
|
||||
).probeSlack(token, timeoutMs, ...slackApiUrlOptionArgs());
|
||||
return await (await loadSlackProbeModule()).probeSlack(token, timeoutMs);
|
||||
},
|
||||
formatCapabilitiesProbe: ({ probe }) => {
|
||||
const slackProbe = probe as SlackProbe | undefined;
|
||||
@@ -739,14 +715,13 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
const botToken = account.botToken?.trim();
|
||||
const userToken = account.userToken?.trim();
|
||||
const { fetchSlackScopes } = await loadSlackScopesModule();
|
||||
const apiUrlOptionArgs = slackApiUrlOptionArgs();
|
||||
const botScopes: SlackScopesResultShape = botToken
|
||||
? await fetchSlackScopes(botToken, timeoutMs, ...apiUrlOptionArgs)
|
||||
? await fetchSlackScopes(botToken, timeoutMs)
|
||||
: { ok: false, error: "Slack bot token missing." };
|
||||
lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes }));
|
||||
details.botScopes = botScopes;
|
||||
if (userToken) {
|
||||
const userScopes = await fetchSlackScopes(userToken, timeoutMs, ...apiUrlOptionArgs);
|
||||
const userScopes = await fetchSlackScopes(userToken, timeoutMs);
|
||||
lines.push(formatSlackScopeDiagnostic({ tokenType: "user", result: userScopes }));
|
||||
details.userScopes = userScopes;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { Agent } from "node:http";
|
||||
import type { RetryOptions, WebClientOptions } from "@slack/web-api";
|
||||
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
|
||||
export type SlackApiUrlClientOptions = Pick<WebClientOptions, "slackApiUrl">;
|
||||
|
||||
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
||||
retries: 2,
|
||||
factor: 2,
|
||||
@@ -32,11 +30,12 @@ export const SLACK_WRITE_RETRY_OPTIONS: RetryOptions = {
|
||||
* Returns `undefined` when no proxy env var is configured or when Slack hosts
|
||||
* are excluded by `NO_PROXY`.
|
||||
*/
|
||||
function resolveSlackProxyAgent(targetUrl: string): Agent | undefined {
|
||||
function resolveSlackProxyAgent(): Agent | undefined {
|
||||
try {
|
||||
return createNodeProxyAgent({
|
||||
mode: "env",
|
||||
targetUrl,
|
||||
targetUrl: "https://slack.com/",
|
||||
protocol: "https",
|
||||
});
|
||||
} catch {
|
||||
// Malformed proxy URL; degrade gracefully to direct connection.
|
||||
@@ -44,38 +43,19 @@ function resolveSlackProxyAgent(targetUrl: string): Agent | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSlackApiUrlFromOptions(
|
||||
options: Pick<WebClientOptions, "slackApiUrl">,
|
||||
): string | undefined {
|
||||
const explicit = options.slackApiUrl?.trim();
|
||||
const envDefault = process.env.SLACK_API_URL?.trim();
|
||||
return explicit || envDefault || undefined;
|
||||
}
|
||||
|
||||
export function createSlackApiUrlClientOptions(): SlackApiUrlClientOptions {
|
||||
const slackApiUrl = process.env.SLACK_API_URL?.trim();
|
||||
return slackApiUrl ? { slackApiUrl } : {};
|
||||
}
|
||||
|
||||
export function resolveSlackWebClientOptions(options: WebClientOptions = {}): WebClientOptions {
|
||||
const slackApiUrl = resolveSlackApiUrlFromOptions(options);
|
||||
const proxyTargetUrl = slackApiUrl ?? "https://slack.com/";
|
||||
return {
|
||||
...options,
|
||||
agent: options.agent ?? resolveSlackProxyAgent(proxyTargetUrl),
|
||||
agent: options.agent ?? resolveSlackProxyAgent(),
|
||||
retryConfig: options.retryConfig ?? SLACK_DEFAULT_RETRY_OPTIONS,
|
||||
...(slackApiUrl ? { slackApiUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSlackWriteClientOptions(options: WebClientOptions = {}): WebClientOptions {
|
||||
const slackApiUrl = resolveSlackApiUrlFromOptions(options);
|
||||
const proxyTargetUrl = slackApiUrl ?? "https://slack.com/";
|
||||
return {
|
||||
...options,
|
||||
agent: options.agent ?? resolveSlackProxyAgent(proxyTargetUrl),
|
||||
agent: options.agent ?? resolveSlackProxyAgent(),
|
||||
retryConfig: options.retryConfig ?? SLACK_WRITE_RETRY_OPTIONS,
|
||||
maxRequestConcurrency: options.maxRequestConcurrency ?? 1,
|
||||
...(slackApiUrl ? { slackApiUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ let SLACK_DEFAULT_RETRY_OPTIONS: typeof import("./client.js").SLACK_DEFAULT_RETR
|
||||
let SLACK_WRITE_RETRY_OPTIONS: typeof import("./client.js").SLACK_WRITE_RETRY_OPTIONS;
|
||||
let WebClient: ReturnType<typeof vi.fn>;
|
||||
|
||||
const SLACK_API_URL_KEYS = ["SLACK_API_URL", "OPENCLAW_SLACK_API_URL"] as const;
|
||||
const PROXY_KEYS = [
|
||||
"HTTPS_PROXY",
|
||||
"HTTP_PROXY",
|
||||
@@ -57,22 +56,6 @@ function restoreProxyEnvForTest() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSlackApiUrlEnvForTest() {
|
||||
for (const key of SLACK_API_URL_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSlackApiUrlEnvForTest() {
|
||||
for (const key of SLACK_API_URL_KEYS) {
|
||||
if (originalEnv[key] !== undefined) {
|
||||
process.env[key] = originalEnv[key];
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireAgent<T extends { agent?: unknown }>(options: T): NonNullable<T["agent"]> {
|
||||
if (!options.agent) {
|
||||
throw new Error("expected proxy agent");
|
||||
@@ -107,11 +90,6 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
WebClient.mockClear();
|
||||
clearSlackWriteClientCacheForTest();
|
||||
clearSlackApiUrlEnvForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreSlackApiUrlEnvForTest();
|
||||
});
|
||||
|
||||
describe("slack web client config", () => {
|
||||
@@ -128,40 +106,6 @@ describe("slack web client config", () => {
|
||||
expect(options.retryConfig).toBe(customRetry);
|
||||
});
|
||||
|
||||
it("uses explicit Slack API URL as the Slack Web API root", () => {
|
||||
expect(
|
||||
resolveSlackWebClientOptions({ slackApiUrl: "http://127.0.0.1:49152/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49152/api/");
|
||||
expect(
|
||||
resolveSlackWriteClientOptions({ slackApiUrl: "http://127.0.0.1:49152/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49152/api/");
|
||||
});
|
||||
|
||||
it("uses SLACK_API_URL as the default Slack Web API root", () => {
|
||||
process.env.SLACK_API_URL = " http://127.0.0.1:49152/api/ ";
|
||||
|
||||
expect(resolveSlackWebClientOptions().slackApiUrl).toBe("http://127.0.0.1:49152/api/");
|
||||
expect(resolveSlackWriteClientOptions().slackApiUrl).toBe("http://127.0.0.1:49152/api/");
|
||||
});
|
||||
|
||||
it("does not read OPENCLAW_SLACK_API_URL as a default Slack Web API root", () => {
|
||||
process.env.OPENCLAW_SLACK_API_URL = "http://127.0.0.1:49152/api/";
|
||||
|
||||
expect(resolveSlackWebClientOptions().slackApiUrl).toBeUndefined();
|
||||
expect(resolveSlackWriteClientOptions().slackApiUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers explicit Slack API URL over SLACK_API_URL", () => {
|
||||
process.env.SLACK_API_URL = "http://127.0.0.1:49152/api/";
|
||||
|
||||
expect(
|
||||
resolveSlackWebClientOptions({ slackApiUrl: "http://127.0.0.1:49153/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49153/api/");
|
||||
expect(
|
||||
resolveSlackWriteClientOptions({ slackApiUrl: "http://127.0.0.1:49153/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49153/api/");
|
||||
});
|
||||
|
||||
it("passes merged options into WebClient", () => {
|
||||
const customAgent = {} as never;
|
||||
|
||||
@@ -246,38 +190,6 @@ describe("slack web client config", () => {
|
||||
expect(WebClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps write clients separated by Slack API URL", () => {
|
||||
clearProxyEnvForTest();
|
||||
try {
|
||||
const first = getSlackWriteClient("xoxb-test", {
|
||||
slackApiUrl: "http://127.0.0.1:49152/api/",
|
||||
});
|
||||
const second = getSlackWriteClient("xoxb-test", {
|
||||
slackApiUrl: "http://127.0.0.1:49153/api/",
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(WebClient).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
restoreProxyEnvForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps write clients separated by SLACK_API_URL", () => {
|
||||
clearProxyEnvForTest();
|
||||
try {
|
||||
process.env.SLACK_API_URL = "http://127.0.0.1:49152/api/";
|
||||
const first = getSlackWriteClient("xoxb-test");
|
||||
process.env.SLACK_API_URL = "http://127.0.0.1:49153/api/";
|
||||
const second = getSlackWriteClient("xoxb-test");
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(WebClient).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
restoreProxyEnvForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds stable non-secret token cache keys", () => {
|
||||
const token = "xoxb-sensitive-token";
|
||||
const first = createSlackTokenCacheKey(token);
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
// Slack plugin module implements client behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { type WebClientOptions, WebClient } from "@slack/web-api";
|
||||
import {
|
||||
resolveSlackWebClientOptions,
|
||||
resolveSlackWriteClientOptions,
|
||||
type SlackApiUrlClientOptions,
|
||||
} from "./client-options.js";
|
||||
import { resolveSlackWebClientOptions, resolveSlackWriteClientOptions } from "./client-options.js";
|
||||
|
||||
const SLACK_WRITE_CLIENT_CACHE_MAX = 32;
|
||||
const slackWriteClientCache = new Map<string, WebClient>();
|
||||
|
||||
export type SlackWriteClientCacheOptions = SlackApiUrlClientOptions;
|
||||
|
||||
export {
|
||||
createSlackApiUrlClientOptions,
|
||||
resolveSlackWebClientOptions,
|
||||
resolveSlackWriteClientOptions,
|
||||
type SlackApiUrlClientOptions,
|
||||
SLACK_DEFAULT_RETRY_OPTIONS,
|
||||
SLACK_WRITE_RETRY_OPTIONS,
|
||||
} from "./client-options.js";
|
||||
@@ -33,27 +25,15 @@ export function createSlackTokenCacheKey(token: string): string {
|
||||
return `sha256:${createHash("sha256").update(token).digest("base64url")}`;
|
||||
}
|
||||
|
||||
function createSlackWriteClientCacheKey(
|
||||
token: string,
|
||||
options: SlackWriteClientCacheOptions,
|
||||
): string {
|
||||
export function getSlackWriteClient(token: string): WebClient {
|
||||
const tokenKey = createSlackTokenCacheKey(token);
|
||||
return options.slackApiUrl ? `${tokenKey}:api:${options.slackApiUrl}` : tokenKey;
|
||||
}
|
||||
|
||||
export function getSlackWriteClient(
|
||||
token: string,
|
||||
options: SlackWriteClientCacheOptions = {},
|
||||
): WebClient {
|
||||
const resolvedOptions = resolveSlackWriteClientOptions(options);
|
||||
const tokenKey = createSlackWriteClientCacheKey(token, resolvedOptions);
|
||||
const cached = slackWriteClientCache.get(tokenKey);
|
||||
if (cached) {
|
||||
slackWriteClientCache.delete(tokenKey);
|
||||
slackWriteClientCache.set(tokenKey, cached);
|
||||
return cached;
|
||||
}
|
||||
const client = new WebClient(token, resolvedOptions);
|
||||
const client = createSlackWriteClient(token);
|
||||
if (slackWriteClientCache.size >= SLACK_WRITE_CLIENT_CACHE_MAX) {
|
||||
const oldestTokenKey = slackWriteClientCache.keys().next().value;
|
||||
if (oldestTokenKey) {
|
||||
|
||||
@@ -38,22 +38,6 @@ describe("slack config schema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects Slack Web API URL config overrides", () => {
|
||||
const res = SlackConfigSchema.safeParse({
|
||||
apiUrl: "http://127.0.0.1:49152/api/",
|
||||
accounts: { ops: { apiUrl: "http://127.0.0.1:49153/api/" } },
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
if (!res.success) {
|
||||
expect(
|
||||
res.error.issues.some(
|
||||
(issue) => issue.code === "unrecognized_keys" && issue.keys.includes("apiUrl"),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts unfurl controls at root and account level", () => {
|
||||
const res = SlackConfigSchema.safeParse({
|
||||
unfurlLinks: false,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
|
||||
type SlackUser = {
|
||||
@@ -51,10 +50,9 @@ type SlackAuthTestResponse = {
|
||||
team?: string;
|
||||
};
|
||||
|
||||
function createSlackDirectoryClient(params: DirectoryConfigParams) {
|
||||
function resolveReadToken(params: DirectoryConfigParams): string | undefined {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const token = account.userToken ?? account.botToken?.trim();
|
||||
return token ? createSlackWebClient(token, createSlackApiUrlClientOptions()) : null;
|
||||
return account.userToken ?? account.botToken?.trim();
|
||||
}
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
@@ -103,10 +101,11 @@ function slackUserToDirectoryEntry(
|
||||
export async function getSlackDirectorySelfLive(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry | null> {
|
||||
const client = createSlackDirectoryClient(params);
|
||||
if (!client) {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const client = createSlackWebClient(token);
|
||||
const auth = (await client.auth.test()) as SlackAuthTestResponse;
|
||||
const userId = normalizeOptionalString(auth.user_id);
|
||||
if (!userId) {
|
||||
@@ -126,10 +125,11 @@ export async function getSlackDirectorySelfLive(
|
||||
export async function listSlackDirectoryPeersLive(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const client = createSlackDirectoryClient(params);
|
||||
if (!client) {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
const client = createSlackWebClient(token);
|
||||
const query = normalizeQuery(params.query);
|
||||
const members: SlackUser[] = [];
|
||||
let cursor: string | undefined;
|
||||
@@ -172,10 +172,11 @@ export async function listSlackDirectoryPeersLive(
|
||||
export async function listSlackDirectoryGroupsLive(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const client = createSlackDirectoryClient(params);
|
||||
if (!client) {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
const client = createSlackWebClient(token);
|
||||
const query = normalizeQuery(params.query);
|
||||
const channels: SlackChannel[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
resolveSlackAccountDmPolicy,
|
||||
} from "../accounts.js";
|
||||
import { isSlackAnyNativeApprovalClientEnabled } from "../approval-native-gates.js";
|
||||
import { createSlackApiUrlClientOptions, resolveSlackWebClientOptions } from "../client-options.js";
|
||||
import { resolveSlackWebClientOptions } from "../client-options.js";
|
||||
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
|
||||
import { SLACK_TEXT_LIMIT } from "../limits.js";
|
||||
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
|
||||
@@ -288,8 +288,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const typingReaction = slackCfg.typingReaction?.trim() ?? "";
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||
const slackApiUrlClientOptions = createSlackApiUrlClientOptions();
|
||||
const clientOptions = resolveSlackWebClientOptions(slackApiUrlClientOptions);
|
||||
const clientOptions = resolveSlackWebClientOptions();
|
||||
const { app, receiver, socketModeLogger } = createSlackBoltApp({
|
||||
interop: await getSlackBoltInterop(),
|
||||
slackMode,
|
||||
@@ -463,7 +462,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const resolved = await resolveSlackChannelAllowlist({
|
||||
token: resolveToken,
|
||||
entries,
|
||||
...slackApiUrlClientOptions,
|
||||
});
|
||||
const nextChannels = { ...channelsConfig };
|
||||
const mapping: string[] = [];
|
||||
@@ -509,7 +507,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const resolvedUsers = await resolveSlackUserAllowlist({
|
||||
token: resolveToken,
|
||||
entries: allowEntries,
|
||||
...slackApiUrlClientOptions,
|
||||
});
|
||||
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(
|
||||
resolvedUsers,
|
||||
@@ -556,7 +553,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const resolvedUsers = await resolveSlackUserAllowlist({
|
||||
token: resolveToken,
|
||||
entries: Array.from(userEntries),
|
||||
...slackApiUrlClientOptions,
|
||||
});
|
||||
const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(
|
||||
resolvedUsers,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Slack plugin module implements probe behavior.
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { createSlackWebClient, type SlackApiUrlClientOptions } from "./client.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { formatSlackError } from "./errors.js";
|
||||
|
||||
export type SlackProbe = BaseProbeResult & {
|
||||
@@ -11,14 +11,8 @@ export type SlackProbe = BaseProbeResult & {
|
||||
team?: { id?: string; name?: string };
|
||||
};
|
||||
|
||||
export async function probeSlack(
|
||||
token: string,
|
||||
timeoutMs = 2500,
|
||||
options: SlackApiUrlClientOptions = {},
|
||||
): Promise<SlackProbe> {
|
||||
const client = options.slackApiUrl
|
||||
? createSlackWebClient(token, options)
|
||||
: createSlackWebClient(token);
|
||||
export async function probeSlack(token: string, timeoutMs = 2500): Promise<SlackProbe> {
|
||||
const client = createSlackWebClient(token);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await withTimeout(client.auth.test(), timeoutMs);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Slack plugin module implements resolve channels behavior.
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { SlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import {
|
||||
collectSlackCursorItems,
|
||||
@@ -102,14 +101,8 @@ export async function resolveSlackChannelAllowlist(params: {
|
||||
token: string;
|
||||
entries: string[];
|
||||
client?: WebClient;
|
||||
slackApiUrl?: SlackApiUrlClientOptions["slackApiUrl"];
|
||||
}): Promise<SlackChannelResolution[]> {
|
||||
const client =
|
||||
params.client ??
|
||||
createSlackWebClient(
|
||||
params.token,
|
||||
params.slackApiUrl ? { slackApiUrl: params.slackApiUrl } : {},
|
||||
);
|
||||
const client = params.client ?? createSlackWebClient(params.token);
|
||||
const channels = await listSlackChannels(client);
|
||||
return resolveSlackAllowlistEntries<
|
||||
{ id?: string; name?: string },
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { SlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import {
|
||||
collectSlackCursorItems,
|
||||
@@ -154,14 +153,8 @@ export async function resolveSlackUserAllowlist(params: {
|
||||
token: string;
|
||||
entries: string[];
|
||||
client?: WebClient;
|
||||
slackApiUrl?: SlackApiUrlClientOptions["slackApiUrl"];
|
||||
}): Promise<SlackUserResolution[]> {
|
||||
const client =
|
||||
params.client ??
|
||||
createSlackWebClient(
|
||||
params.token,
|
||||
params.slackApiUrl ? { slackApiUrl: params.slackApiUrl } : {},
|
||||
);
|
||||
const client = params.client ?? createSlackWebClient(params.token);
|
||||
const users = await listSlackUsers(client);
|
||||
return resolveSlackAllowlistEntries<
|
||||
{ id?: string; name?: string; email?: string },
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
sortUniqueStrings,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { createSlackWebClient, type SlackApiUrlClientOptions } from "./client.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { formatSlackError } from "./errors.js";
|
||||
|
||||
export type SlackScopesResult = {
|
||||
@@ -95,9 +95,8 @@ async function callSlack(
|
||||
export async function fetchSlackScopes(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
options: SlackApiUrlClientOptions = {},
|
||||
): Promise<SlackScopesResult> {
|
||||
const client = createSlackWebClient(token, { ...options, timeout: timeoutMs });
|
||||
const client = createSlackWebClient(token, { timeout: timeoutMs });
|
||||
const attempts: SlackScopesMethod[] = ["auth.test", "auth.scopes", "apps.permissions.info"];
|
||||
const errors: string[] = [];
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import type { SlackTokenSource } from "./accounts.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
|
||||
import { validateSlackBlocksArray } from "./blocks-input.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackTokenCacheKey, getSlackWriteClient } from "./client.js";
|
||||
import { markdownToSlackMrkdwnChunks } from "./format.js";
|
||||
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||
@@ -751,7 +750,7 @@ async function sendMessageSlackQueuedInner(params: {
|
||||
blocks?: (Block | KnownBlock)[];
|
||||
}): Promise<SlackSendResult> {
|
||||
const { opts, cfg, account, token, recipient, blocks, trimmedMessage } = params;
|
||||
const client = opts.client ?? getSlackWriteClient(token, createSlackApiUrlClientOptions());
|
||||
const client = opts.client ?? getSlackWriteClient(token);
|
||||
const identity = resolveSlackSendIdentity({
|
||||
accountId: account.accountId,
|
||||
explicit: opts.identity,
|
||||
|
||||
@@ -1778,6 +1778,7 @@
|
||||
"test:docker:crestodian-first-run": "bash scripts/e2e/crestodian-first-run-docker.sh",
|
||||
"test:docker:crestodian-planner": "bash scripts/e2e/crestodian-planner-docker.sh",
|
||||
"test:docker:crestodian-rescue": "bash scripts/e2e/crestodian-rescue-docker.sh",
|
||||
"test:docker:cron-cli": "bash scripts/e2e/cron-cli-docker.sh",
|
||||
"test:docker:cron-mcp-cleanup": "bash scripts/e2e/cron-mcp-cleanup-docker.sh",
|
||||
"test:docker:codex-media-path": "bash scripts/e2e/codex-media-path-docker.sh",
|
||||
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
|
||||
|
||||
@@ -164,6 +164,126 @@ describe("agentLoop streaming updates", () => {
|
||||
expect(update.assistantMessageEvent).not.toHaveProperty("partial");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not execute tool calls from a max-token-truncated assistant turn", async () => {
|
||||
const execute = vi.fn(
|
||||
async (): Promise<AgentToolResult<unknown>> => ({
|
||||
content: [{ type: "text", text: "should not run" }],
|
||||
details: {},
|
||||
}),
|
||||
);
|
||||
const contexts: Context[] = [];
|
||||
let streamCalls = 0;
|
||||
const streamFn: StreamFn = async (_model, context) => {
|
||||
contexts.push(context);
|
||||
streamCalls += 1;
|
||||
const stream = createAssistantMessageEventStream();
|
||||
if (streamCalls > 1) {
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "continued" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: TEST_USAGE,
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
const toolCall = {
|
||||
type: "toolCall" as const,
|
||||
id: "call-truncated-spawn",
|
||||
name: "sessions_spawn",
|
||||
arguments: {},
|
||||
};
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "spawning" }, toolCall],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: TEST_USAGE,
|
||||
stopReason: "length",
|
||||
timestamp: 1,
|
||||
};
|
||||
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: { ...message, content: [] } });
|
||||
stream.push({ type: "toolcall_start", contentIndex: 1, partial: message });
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: 1,
|
||||
toolCall,
|
||||
partial: message,
|
||||
});
|
||||
stream.push({ type: "done", reason: "length", message });
|
||||
});
|
||||
|
||||
return stream;
|
||||
};
|
||||
|
||||
const stream = agentLoop(
|
||||
[{ role: "user", content: "spawn specialists", timestamp: 1 }],
|
||||
{
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
name: "sessions_spawn",
|
||||
label: "sessions_spawn",
|
||||
description: "Spawn a child session",
|
||||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
execute,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...config,
|
||||
getFollowUpMessages: async () =>
|
||||
streamCalls === 1 ? [{ role: "user", content: "continue", timestamp: 2 }] : [],
|
||||
},
|
||||
undefined,
|
||||
streamFn,
|
||||
);
|
||||
|
||||
const events = await collectEvents(stream);
|
||||
const messages = await stream.result();
|
||||
const truncatedMessageEnd = events.find(
|
||||
(event): event is Extract<AgentEvent, { type: "message_end" }> =>
|
||||
event.type === "message_end" &&
|
||||
event.message.role === "assistant" &&
|
||||
event.message.stopReason === "length",
|
||||
);
|
||||
const replayedTruncatedMessage = contexts[1]?.messages[1];
|
||||
|
||||
if (!truncatedMessageEnd || !replayedTruncatedMessage) {
|
||||
throw new Error("expected the truncated assistant message to be emitted and replayed");
|
||||
}
|
||||
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(events.some((event) => event.type === "tool_execution_start")).toBe(false);
|
||||
expect(messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "length" });
|
||||
expect(messages[1]).not.toMatchObject({
|
||||
content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]),
|
||||
});
|
||||
expect(truncatedMessageEnd.message).not.toMatchObject({
|
||||
content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]),
|
||||
});
|
||||
expect(replayedTruncatedMessage).toMatchObject({ role: "assistant", stopReason: "length" });
|
||||
expect(replayedTruncatedMessage).not.toMatchObject({
|
||||
content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAgentLoop deferred tool hydration", () => {
|
||||
@@ -936,7 +1056,7 @@ describe("agentLoop thinking state", () => {
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
stopReason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop",
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
@@ -968,7 +1088,7 @@ describe("agentLoop thinking state", () => {
|
||||
: [{ type: "text", text: "done" }];
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
reason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop",
|
||||
message: makeAssistantMessage(activeModel, content),
|
||||
});
|
||||
stream.end();
|
||||
|
||||
@@ -80,6 +80,14 @@ function resolveAssistantMessageUpdate(
|
||||
return currentMessage;
|
||||
}
|
||||
|
||||
function removeNonExecutableToolCalls(message: AssistantMessage): AssistantMessage {
|
||||
if (message.stopReason === "toolUse") {
|
||||
return message;
|
||||
}
|
||||
const content = message.content.filter((item) => item.type !== "toolCall");
|
||||
return content.length === message.content.length ? message : { ...message, content };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an agent loop with a new prompt message.
|
||||
* The prompt is added to the context and events are emitted for it.
|
||||
@@ -342,12 +350,12 @@ async function runLoop(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for tool calls
|
||||
// Only completed toolUse turns dispatch; length/stop can carry partial stream blocks.
|
||||
const toolCalls = message.content.filter((c) => c.type === "toolCall");
|
||||
|
||||
const toolResults: ToolResultMessage[] = [];
|
||||
hasMoreToolCalls = false;
|
||||
if (toolCalls.length > 0) {
|
||||
if (message.stopReason === "toolUse" && toolCalls.length > 0) {
|
||||
const executedToolBatch = await executeToolCalls(
|
||||
currentContext,
|
||||
message,
|
||||
@@ -508,7 +516,7 @@ async function streamAssistantResponse(
|
||||
|
||||
case "done":
|
||||
case "error": {
|
||||
const finalMessage = await response.result();
|
||||
const finalMessage = removeNonExecutableToolCalls(await response.result());
|
||||
if (addedPartial) {
|
||||
context.messages[context.messages.length - 1] = finalMessage;
|
||||
} else {
|
||||
@@ -523,7 +531,7 @@ async function streamAssistantResponse(
|
||||
}
|
||||
}
|
||||
|
||||
const finalMessage = await response.result();
|
||||
const finalMessage = removeNonExecutableToolCalls(await response.result());
|
||||
if (addedPartial) {
|
||||
context.messages[context.messages.length - 1] = finalMessage;
|
||||
} else {
|
||||
|
||||
@@ -328,6 +328,7 @@ type SelectedConnectAuth = {
|
||||
authDeviceToken?: string;
|
||||
authPassword?: string;
|
||||
authApprovalRuntimeToken?: string;
|
||||
authAgentRuntimeIdentityToken?: string;
|
||||
signatureToken?: string;
|
||||
resolvedDeviceToken?: string;
|
||||
storedToken?: string;
|
||||
@@ -343,6 +344,7 @@ type StoredDeviceAuth = {
|
||||
type AssembledConnect = {
|
||||
params: ConnectParams;
|
||||
authApprovalRuntimeToken: string | undefined;
|
||||
authAgentRuntimeIdentityToken: string | undefined;
|
||||
resolvedDeviceToken: string | undefined;
|
||||
storedToken: string | undefined;
|
||||
usingStoredDeviceToken: boolean | undefined;
|
||||
@@ -430,6 +432,7 @@ export type GatewayClientOptions = {
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
instanceId?: string;
|
||||
clientName?: GatewayClientName;
|
||||
clientDisplayName?: string;
|
||||
@@ -969,6 +972,24 @@ 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,
|
||||
@@ -1004,6 +1025,7 @@ export class GatewayClient {
|
||||
authDeviceToken,
|
||||
authPassword,
|
||||
authApprovalRuntimeToken,
|
||||
authAgentRuntimeIdentityToken,
|
||||
signatureToken,
|
||||
resolvedDeviceToken,
|
||||
storedToken,
|
||||
@@ -1020,13 +1042,15 @@ export class GatewayClient {
|
||||
authBootstrapToken ||
|
||||
authPassword ||
|
||||
resolvedDeviceToken ||
|
||||
authApprovalRuntimeToken
|
||||
authApprovalRuntimeToken ||
|
||||
authAgentRuntimeIdentityToken
|
||||
? {
|
||||
token: authToken,
|
||||
bootstrapToken: authBootstrapToken,
|
||||
deviceToken: authDeviceToken ?? resolvedDeviceToken,
|
||||
password: authPassword,
|
||||
approvalRuntimeToken: authApprovalRuntimeToken,
|
||||
agentRuntimeIdentityToken: authAgentRuntimeIdentityToken,
|
||||
}
|
||||
: undefined;
|
||||
const signedAtMs = Date.now();
|
||||
@@ -1069,6 +1093,7 @@ export class GatewayClient {
|
||||
}),
|
||||
},
|
||||
authApprovalRuntimeToken,
|
||||
authAgentRuntimeIdentityToken,
|
||||
resolvedDeviceToken,
|
||||
storedToken,
|
||||
usingStoredDeviceToken,
|
||||
@@ -1294,6 +1319,25 @@ 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 {
|
||||
@@ -1321,6 +1365,9 @@ 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;
|
||||
@@ -1354,6 +1401,7 @@ export class GatewayClient {
|
||||
authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined,
|
||||
authPassword,
|
||||
authApprovalRuntimeToken,
|
||||
authAgentRuntimeIdentityToken,
|
||||
signatureToken: authToken ?? authBootstrapToken ?? undefined,
|
||||
resolvedDeviceToken,
|
||||
storedToken: storedToken ?? undefined,
|
||||
|
||||
@@ -26,11 +26,36 @@ 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,6 +70,7 @@ 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 },
|
||||
),
|
||||
|
||||
251
scripts/e2e/cron-cli-docker.sh
Executable file
251
scripts/e2e/cron-cli-docker.sh
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts a packaged Gateway in Docker and verifies public cron CLI CRUD/run flows.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
||||
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-cron-cli-e2e" OPENCLAW_IMAGE)"
|
||||
PORT="18789"
|
||||
TOKEN="cron-cli-e2e-$(date +%s)-$$"
|
||||
CONTAINER_NAME="openclaw-cron-cli-e2e-$$"
|
||||
CLIENT_LOG="$(mktemp -t openclaw-cron-cli-log.XXXXXX)"
|
||||
|
||||
cleanup() {
|
||||
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
rm -f "$CLIENT_LOG"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" cron-cli
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 cron-cli empty)"
|
||||
|
||||
echo "Running in-container Gateway + cron CLI smoke..."
|
||||
set +e
|
||||
docker_e2e_run_with_harness \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
|
||||
-e "OPENCLAW_SKIP_CHANNELS=1" \
|
||||
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
|
||||
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
|
||||
-e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \
|
||||
-e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
-e "GW_TOKEN=$TOKEN" \
|
||||
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
|
||||
-i \
|
||||
"$IMAGE_NAME" \
|
||||
bash -s >"$CLIENT_LOG" 2>&1 <<'INNER'
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
|
||||
entry="$(openclaw_e2e_resolve_entrypoint)"
|
||||
gateway_pid=
|
||||
|
||||
cleanup_inner() {
|
||||
openclaw_e2e_stop_process "${gateway_pid:-}"
|
||||
}
|
||||
|
||||
dump_logs_on_error() {
|
||||
status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
openclaw_e2e_dump_logs \
|
||||
/tmp/cron-cli-gateway.log \
|
||||
/tmp/cron-cli-device-seed.json \
|
||||
/tmp/cron-cli-status.json \
|
||||
/tmp/cron-cli-add.json \
|
||||
/tmp/cron-cli-list.json \
|
||||
/tmp/cron-cli-show.json \
|
||||
/tmp/cron-cli-disable.json \
|
||||
/tmp/cron-cli-enable.json \
|
||||
/tmp/cron-cli-run.json \
|
||||
/tmp/cron-cli-runs.json \
|
||||
/tmp/cron-cli-remove.json
|
||||
fi
|
||||
cleanup_inner
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
trap cleanup_inner EXIT
|
||||
trap dump_logs_on_error ERR
|
||||
|
||||
cron_cli() {
|
||||
node "$entry" cron "$@" --token "${GW_TOKEN:?missing GW_TOKEN}"
|
||||
}
|
||||
|
||||
seed_paired_cli_device() {
|
||||
node --input-type=module <<'NODE'
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
async function importDistChunk(prefix, marker) {
|
||||
const distDir = join(process.cwd(), "dist");
|
||||
const entries = await readdir(distDir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith(prefix) || !entry.endsWith(".js")) {
|
||||
continue;
|
||||
}
|
||||
const fullPath = join(distDir, entry);
|
||||
if ((await readFile(fullPath, "utf8")).includes(marker)) {
|
||||
return await import(pathToFileURL(fullPath).href);
|
||||
}
|
||||
}
|
||||
throw new Error(`missing dist chunk ${prefix} containing ${marker}`);
|
||||
}
|
||||
|
||||
const identityModule = await importDistChunk("device-identity-", "loadOrCreateDeviceIdentity");
|
||||
const pairingModule = await importDistChunk("device-pairing-", "requestDevicePairing");
|
||||
const loadOrCreateDeviceIdentity =
|
||||
identityModule.loadOrCreateDeviceIdentity ?? identityModule.r;
|
||||
const publicKeyRawBase64UrlFromPem =
|
||||
identityModule.publicKeyRawBase64UrlFromPem ?? identityModule.a;
|
||||
const approveDevicePairing = pairingModule.approveDevicePairing ?? pairingModule.n;
|
||||
const getPairedDevice = pairingModule.getPairedDevice ?? pairingModule.a;
|
||||
const requestDevicePairing = pairingModule.requestDevicePairing ?? pairingModule.m;
|
||||
|
||||
if (
|
||||
typeof loadOrCreateDeviceIdentity !== "function" ||
|
||||
typeof publicKeyRawBase64UrlFromPem !== "function" ||
|
||||
typeof approveDevicePairing !== "function" ||
|
||||
typeof getPairedDevice !== "function" ||
|
||||
typeof requestDevicePairing !== "function"
|
||||
) {
|
||||
throw new Error("missing device pairing exports in dist chunks");
|
||||
}
|
||||
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const requiredScopes = ["operator.admin"];
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
const pairedScopes = Array.isArray(paired?.approvedScopes)
|
||||
? paired.approvedScopes
|
||||
: Array.isArray(paired?.scopes)
|
||||
? paired.scopes
|
||||
: [];
|
||||
|
||||
if (paired?.publicKey !== publicKey || !requiredScopes.every((scope) => pairedScopes.includes(scope))) {
|
||||
const pairing = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey,
|
||||
displayName: "cron cli docker smoke",
|
||||
platform: process.platform,
|
||||
clientId: "cli",
|
||||
clientMode: "cli",
|
||||
role: "operator",
|
||||
scopes: requiredScopes,
|
||||
silent: true,
|
||||
});
|
||||
const approved = await approveDevicePairing(pairing.request.requestId, {
|
||||
callerScopes: requiredScopes,
|
||||
});
|
||||
if (approved?.status !== "approved") {
|
||||
throw new Error(`failed to seed paired CLI device: ${approved?.status ?? "missing-result"}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ ok: true, deviceId: identity.deviceId }) + "\n");
|
||||
NODE
|
||||
}
|
||||
|
||||
read_json_field() {
|
||||
local file="$1"
|
||||
local field="$2"
|
||||
node --input-type=module -e '
|
||||
const fs = await import("node:fs/promises");
|
||||
const [file, field] = process.argv.slice(1);
|
||||
const value = JSON.parse(await fs.readFile(file, "utf8"))[field];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`missing string field ${field} in ${file}`);
|
||||
}
|
||||
process.stdout.write(value);
|
||||
' "$file" "$field"
|
||||
}
|
||||
|
||||
seed_paired_cli_device > /tmp/cron-cli-device-seed.json
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
|
||||
|
||||
cron_cli status --json > /tmp/cron-cli-status.json
|
||||
cron_add_args=(
|
||||
"cli cron smoke"
|
||||
--every 1m
|
||||
--command "printf openclaw-cli-cron-ok"
|
||||
--no-deliver
|
||||
--timeout-seconds 15
|
||||
--json
|
||||
)
|
||||
cron_cli add "${cron_add_args[@]}" > /tmp/cron-cli-add.json
|
||||
|
||||
job_id="$(read_json_field /tmp/cron-cli-add.json id)"
|
||||
|
||||
cron_cli list --all --json > /tmp/cron-cli-list.json
|
||||
node --input-type=module -e '
|
||||
const fs = await import("node:fs/promises");
|
||||
const jobId = process.argv[1];
|
||||
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-list.json", "utf8"));
|
||||
if (!Array.isArray(value.jobs) || !value.jobs.some((job) => job.id === jobId && job.name === "cli cron smoke")) {
|
||||
throw new Error("created job missing from cron list");
|
||||
}
|
||||
' "$job_id"
|
||||
|
||||
cron_cli show "$job_id" --json > /tmp/cron-cli-show.json
|
||||
node --input-type=module -e '
|
||||
const fs = await import("node:fs/promises");
|
||||
const jobId = process.argv[1];
|
||||
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-show.json", "utf8"));
|
||||
if (value.id !== jobId || value.name !== "cli cron smoke") {
|
||||
throw new Error("cron show returned the wrong job");
|
||||
}
|
||||
' "$job_id"
|
||||
|
||||
cron_cli disable "$job_id" > /tmp/cron-cli-disable.json
|
||||
cron_cli enable "$job_id" > /tmp/cron-cli-enable.json
|
||||
|
||||
cron_cli run "$job_id" --wait --wait-timeout 120s --poll-interval 500ms > /tmp/cron-cli-run.json
|
||||
node --input-type=module -e '
|
||||
const fs = await import("node:fs/promises");
|
||||
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-run.json", "utf8"));
|
||||
if (value.completed !== true || value.status !== "ok") {
|
||||
throw new Error(`cron run did not complete ok: ${JSON.stringify(value)}`);
|
||||
}
|
||||
'
|
||||
|
||||
cron_cli runs --id "$job_id" --limit 5 > /tmp/cron-cli-runs.json
|
||||
node --input-type=module -e '
|
||||
const fs = await import("node:fs/promises");
|
||||
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-runs.json", "utf8"));
|
||||
const matching = Array.isArray(value.entries)
|
||||
? value.entries.find((entry) => entry.status === "ok" && entry.summary === "openclaw-cli-cron-ok")
|
||||
: undefined;
|
||||
if (!matching) {
|
||||
throw new Error("cron runs missing successful command summary");
|
||||
}
|
||||
'
|
||||
|
||||
cron_cli rm "$job_id" --json > /tmp/cron-cli-remove.json
|
||||
node --input-type=module -e '
|
||||
const fs = await import("node:fs/promises");
|
||||
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-remove.json", "utf8"));
|
||||
if (value.ok !== true) {
|
||||
throw new Error("cron remove failed");
|
||||
}
|
||||
'
|
||||
|
||||
node --input-type=module -e '
|
||||
process.stdout.write(JSON.stringify({ ok: true, jobId: process.argv[1] }) + "\n");
|
||||
' "$job_id"
|
||||
INNER
|
||||
status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "Docker cron CLI smoke failed"
|
||||
docker_e2e_print_log "$CLIENT_LOG"
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
docker_e2e_print_log "$CLIENT_LOG"
|
||||
echo "OK"
|
||||
@@ -7,6 +7,7 @@ import { readPluginInstallIndex } from "../plugin-index-sqlite.mjs";
|
||||
const command = process.argv[2];
|
||||
const SCENARIOS = new Set([
|
||||
"base",
|
||||
"acpx-openclaw-tools-bridge",
|
||||
"feishu-channel",
|
||||
"bootstrap-persona",
|
||||
"channel-post-core-restore",
|
||||
@@ -312,6 +313,16 @@ function assertConfigSurvived() {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "acpx-openclaw-tools-bridge")) {
|
||||
const pluginAllow = config.plugins?.allow ?? [];
|
||||
assert(pluginAllow.includes("acpx"), "ACPX plugin allow entry missing");
|
||||
assert(config.plugins?.entries?.acpx?.enabled === true, "ACPX plugin entry changed");
|
||||
assert(
|
||||
config.plugins?.entries?.acpx?.config?.openClawToolsMcpBridge === true,
|
||||
"ACPX OpenClaw tools bridge config changed",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) {
|
||||
const pluginAllow = config.plugins?.allow ?? [];
|
||||
assert(pluginAllow.includes("discord"), "configured install discord allow entry missing");
|
||||
|
||||
@@ -108,6 +108,17 @@ const representativeConfigSteps = [
|
||||
];
|
||||
|
||||
const scenarioConfigSteps = new Map([
|
||||
[
|
||||
"acpx-openclaw-tools-bridge",
|
||||
[
|
||||
configSetJsonFile(
|
||||
"plugins-acpx-openclaw-tools-bridge",
|
||||
"acpx-openclaw-tools-bridge",
|
||||
"plugins",
|
||||
"plugins-acpx-openclaw-tools-bridge.json",
|
||||
),
|
||||
],
|
||||
],
|
||||
[
|
||||
"feishu-channel",
|
||||
[
|
||||
@@ -174,6 +185,15 @@ function selectedScenario() {
|
||||
}
|
||||
|
||||
function adaptStepForBaseline(step, baselineVersion, summary) {
|
||||
if (
|
||||
step.intent === "acpx-openclaw-tools-bridge" &&
|
||||
isReleaseBefore(baselineVersion, "2026.4.22")
|
||||
) {
|
||||
if (!summary.skippedIntents.includes("acpx-openclaw-tools-bridge")) {
|
||||
summary.skippedIntents.push("acpx-openclaw-tools-bridge");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isReleaseBefore(baselineVersion, "2026.4.0")) {
|
||||
return step;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"allow": ["acpx", "discord", "memory", "telegram", "whatsapp"],
|
||||
"entries": {
|
||||
"acpx": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"openClawToolsMcpBridge": true
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": true
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1168,7 +1168,7 @@ refresh_gateway_service_if_loaded() {
|
||||
|
||||
if ! "$claw" gateway restart >/dev/null 2>&1; then
|
||||
emit_json '{"event":"step","name":"gateway-service","status":"warn","reason":"restart-failed"}'
|
||||
log "Warning: gateway service restart failed; continuing."
|
||||
log "Warning: gateway service restart failed; continuing. Run: openclaw gateway restart"
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -1401,7 +1401,7 @@ function Refresh-GatewayServiceIfLoaded {
|
||||
Invoke-OpenClawCommand gateway status --json | Out-Null
|
||||
Write-Host "[OK] Gateway service refreshed" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[!] Gateway service restart failed; continuing." -ForegroundColor Yellow
|
||||
Write-Host "[!] Gateway service restart failed; continuing. Run: openclaw gateway restart" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3000,7 +3000,7 @@ refresh_gateway_service_if_loaded() {
|
||||
if run_quiet_step "Restarting gateway service" "$claw" gateway restart; then
|
||||
ui_success "Gateway service restarted"
|
||||
else
|
||||
ui_warn "Gateway service restart failed; continuing"
|
||||
ui_warn "Gateway service restart failed; continuing. Run: openclaw gateway restart"
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ function sanitizeLaneNameSuffix(value) {
|
||||
|
||||
const UPGRADE_SURVIVOR_SCENARIOS = [
|
||||
"base",
|
||||
"acpx-openclaw-tools-bridge",
|
||||
"feishu-channel",
|
||||
"bootstrap-persona",
|
||||
"channel-post-core-restore",
|
||||
@@ -183,6 +184,17 @@ function supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec) {
|
||||
return comparePublishedReleaseVersion(version, { year: 2026, month: 4, patch: 23 }) >= 0;
|
||||
}
|
||||
|
||||
function supportsUpgradeSurvivorAcpToolsBridge(baselineSpec) {
|
||||
if (!baselineSpec) {
|
||||
return true;
|
||||
}
|
||||
const version = parsePublishedReleaseVersion(baselineSpec);
|
||||
if (!version) {
|
||||
return true;
|
||||
}
|
||||
return comparePublishedReleaseVersion(version, { year: 2026, month: 4, patch: 22 }) >= 0;
|
||||
}
|
||||
|
||||
function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScenarios = "") {
|
||||
const baselineSpecs = parseUpgradeSurvivorBaselineSpecs(rawBaselineSpecs);
|
||||
const scenarios = parseUpgradeSurvivorScenarios(rawScenarios);
|
||||
@@ -199,8 +211,10 @@ function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScen
|
||||
matrixScenarios
|
||||
.filter(
|
||||
(scenario) =>
|
||||
scenario !== "plugin-deps-cleanup" ||
|
||||
supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec),
|
||||
(scenario !== "plugin-deps-cleanup" ||
|
||||
supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec)) &&
|
||||
(scenario !== "acpx-openclaw-tools-bridge" ||
|
||||
supportsUpgradeSurvivorAcpToolsBridge(baselineSpec)),
|
||||
)
|
||||
.map((scenario) => {
|
||||
const suffixParts = [
|
||||
|
||||
@@ -422,6 +422,7 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do
|
||||
-e OPENCLAW_LIVE_TEST=1 \
|
||||
-e OPENCLAW_LIVE_ACP_BIND=1 \
|
||||
-e OPENCLAW_LIVE_ACP_BIND_AGENT="$ACP_AGENT" \
|
||||
-e OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON="${OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON:-}" \
|
||||
-e OPENCLAW_LIVE_ACP_BIND_TEST_FILES="${OPENCLAW_LIVE_ACP_BIND_TEST_FILES:-}" \
|
||||
-e OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL="${OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL:-}" \
|
||||
-e OPENCLAW_LIVE_ACP_BIND_SETUP_TIMEOUT_SECONDS="$ACP_SETUP_TIMEOUT_SECONDS" \
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
createVitestRunSpecs,
|
||||
findUnmatchedExplicitTestTargets,
|
||||
formatFailedShardDigest,
|
||||
formatNoChangedTestTargetLines,
|
||||
listFullExtensionVitestProjectConfigs,
|
||||
orderFullSuiteSpecsForParallelRun,
|
||||
parseTestProjectsArgs,
|
||||
@@ -182,21 +183,9 @@ function isFullExtensionsProjectRun(specs) {
|
||||
function printNoChangedTestTargets(args, cwd, baseEnv) {
|
||||
const plan = resolveChangedTestTargetPlanForArgs(args, cwd, undefined, { env: baseEnv });
|
||||
const skippedBroadFallbackPaths = plan?.skippedBroadFallbackPaths ?? [];
|
||||
if (skippedBroadFallbackPaths.length === 0) {
|
||||
console.error("[test] no changed test targets; skipping Vitest.");
|
||||
return;
|
||||
for (const line of formatNoChangedTestTargetLines(skippedBroadFallbackPaths)) {
|
||||
console.error(line);
|
||||
}
|
||||
|
||||
console.error("[test] no precise changed test targets; skipping Vitest.");
|
||||
console.error(
|
||||
`[test] ${skippedBroadFallbackPaths.length} changed path${
|
||||
skippedBroadFallbackPaths.length === 1 ? "" : "s"
|
||||
} require broad Vitest fallback:`,
|
||||
);
|
||||
for (const changedPath of skippedBroadFallbackPaths) {
|
||||
console.error(`[test] ${changedPath}`);
|
||||
}
|
||||
console.error("[test] run `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` for broad coverage.");
|
||||
}
|
||||
|
||||
async function runVitestSpecsParallel(specs, concurrency) {
|
||||
|
||||
@@ -926,6 +926,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["test/scripts/docker-e2e-seeds.test.ts", "test/scripts/mcp-code-mode-gateway-client.test.ts"],
|
||||
],
|
||||
["scripts/e2e/mcp-client-temp-state.ts", ["test/scripts/mcp-channels-harness.test.ts"]],
|
||||
[
|
||||
"scripts/e2e/cron-cli-docker.sh",
|
||||
["test/scripts/docker-build-helper.test.ts", "test/scripts/docker-e2e-observability.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/cron-mcp-cleanup-docker.sh",
|
||||
[
|
||||
@@ -2156,6 +2160,22 @@ export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS = String(900_000)
|
||||
export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_HEARTBEAT_MS = String(
|
||||
DEFAULT_VITEST_NO_OUTPUT_HEARTBEAT_MS,
|
||||
);
|
||||
|
||||
export function formatNoChangedTestTargetLines(skippedBroadFallbackPaths) {
|
||||
if (skippedBroadFallbackPaths.length === 0) {
|
||||
return ["[test] no changed test targets; skipping Vitest."];
|
||||
}
|
||||
|
||||
return [
|
||||
"[test] no precise changed test targets; skipping Vitest.",
|
||||
`[test] ${skippedBroadFallbackPaths.length} changed path${
|
||||
skippedBroadFallbackPaths.length === 1 ? "" : "s"
|
||||
} require broad Vitest fallback:`,
|
||||
...skippedBroadFallbackPaths.map((changedPath) => `[test] ${changedPath}`),
|
||||
"[test] run `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` for broad coverage.",
|
||||
];
|
||||
}
|
||||
|
||||
const EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD = 12;
|
||||
const GATEWAY_SERVER_FULL_SUITE_TARGET_CHUNK_COUNT = 4;
|
||||
const GATEWAY_SERVER_BACKED_HTTP_TEST_TARGETS = new Set([
|
||||
|
||||
@@ -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,6 +73,18 @@ 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,
|
||||
@@ -83,6 +95,7 @@ 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";
|
||||
@@ -212,10 +225,6 @@ 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;
|
||||
@@ -1558,12 +1567,13 @@ 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,
|
||||
value: hookOptions satisfies BeforeToolCallDiagnosticOptions,
|
||||
enumerable: false,
|
||||
});
|
||||
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_SOURCE_TOOL, {
|
||||
@@ -1577,21 +1587,6 @@ 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,
|
||||
@@ -1618,33 +1613,10 @@ 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,5 +1,4 @@
|
||||
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
|
||||
@@ -7,6 +6,7 @@ import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.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";
|
||||
|
||||
|
||||
49
src/agents/before-tool-call-metadata.ts
Normal file
49
src/agents/before-tool-call-metadata.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -44,6 +44,7 @@ function createDeepSeekCompletionsModel(): Model<"openai-completions"> {
|
||||
api: "openai-completions",
|
||||
provider: "deepseek",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
compat: { thinkingFormat: "deepseek" },
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -1468,7 +1469,7 @@ describe("openai transport stream", () => {
|
||||
name: "qwen-coder-plus",
|
||||
api: "openai-completions",
|
||||
provider: "qwen",
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
baseUrl: "",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -2802,6 +2803,165 @@ describe("openai transport stream", () => {
|
||||
expect(JSON.stringify(events)).not.toContain("DSML");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ finishReason: "length", stopReason: "length" },
|
||||
{ finishReason: "content_filter", stopReason: "error" },
|
||||
])(
|
||||
"does not authorize recovered DeepSeek DSML calls after $finishReason",
|
||||
async ({ finishReason, stopReason }) => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
expect(testing.getCompat(model).thinkingFormat).toBe("deepseek");
|
||||
|
||||
await testing.processOpenAICompletionsStream(
|
||||
streamChunks([
|
||||
{
|
||||
id: "chatcmpl-deepseek-dsml-terminal",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: model.id,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content:
|
||||
'<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/partial.md"}</|DSML|invoke></|DSML|tool_calls>',
|
||||
},
|
||||
logprobs: null,
|
||||
finish_reason: finishReason,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
output,
|
||||
model,
|
||||
{ push() {} },
|
||||
);
|
||||
|
||||
expect(output.stopReason).toBe(stopReason);
|
||||
expect(output.content).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not authorize recovered DeepSeek DSML calls when the stream omits a terminal", async () => {
|
||||
const model = createDeepSeekCompletionsModel();
|
||||
const output = createAssistantOutput(model);
|
||||
|
||||
await testing.processOpenAICompletionsStream(
|
||||
streamChunks([
|
||||
{
|
||||
id: "chatcmpl-deepseek-dsml-no-terminal",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: model.id,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content:
|
||||
'<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/partial.md"}</|DSML|invoke></|DSML|tool_calls>',
|
||||
},
|
||||
logprobs: null,
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
output,
|
||||
model,
|
||||
{ push() {} },
|
||||
);
|
||||
|
||||
expect(output.stopReason).toBe("stop");
|
||||
expect(output.content).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits recovered DeepSeek content-filter terminals as errors", async () => {
|
||||
const server = createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl-deepseek-dsml-content-filter",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "deepseek-v4-pro",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content:
|
||||
'<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/partial.md"}</|DSML|invoke></|DSML|tool_calls>',
|
||||
},
|
||||
finish_reason: "content_filter",
|
||||
},
|
||||
],
|
||||
})}\n\n`,
|
||||
);
|
||||
res.end("data: [DONE]\n\n");
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Missing loopback server address");
|
||||
}
|
||||
const model = {
|
||||
...createDeepSeekCompletionsModel(),
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
} satisfies Model<"openai-completions">;
|
||||
const stream = createOpenAICompletionsTransportStreamFn()(
|
||||
model,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [{ role: "user", content: "Read the file", timestamp: Date.now() }],
|
||||
tools: [],
|
||||
} as never,
|
||||
{ apiKey: "test-key" } as never,
|
||||
);
|
||||
|
||||
const terminalEvents: Array<{
|
||||
type: string;
|
||||
reason?: string;
|
||||
error?: Record<string, unknown>;
|
||||
}> = [];
|
||||
for await (const event of stream as AsyncIterable<{
|
||||
type: string;
|
||||
reason?: string;
|
||||
error?: Record<string, unknown>;
|
||||
}>) {
|
||||
if (event.type === "done" || event.type === "error") {
|
||||
terminalEvents.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
expect(terminalEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: expect.objectContaining({
|
||||
stopReason: "error",
|
||||
errorMessage: "Provider finish_reason: content_filter",
|
||||
content: [],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("parses repeated DeepSeek DSML name attributes consistently", async () => {
|
||||
// Guards the cached attribute matchers: repeated parses must stay identical
|
||||
// (no stale RegExp lastIndex) across separate stream invocations.
|
||||
@@ -6716,6 +6876,7 @@ describe("openai transport stream", () => {
|
||||
api: "openai-completions",
|
||||
provider: "qwen",
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
compat: { supportsUsageInStreaming: true },
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -6745,6 +6906,7 @@ describe("openai transport stream", () => {
|
||||
api: "openai-completions",
|
||||
provider: "generic",
|
||||
baseUrl: "https://coding.dashscope.aliyuncs.com/v1",
|
||||
compat: { supportsUsageInStreaming: true },
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
|
||||
@@ -98,6 +98,8 @@ import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.j
|
||||
import { transformTransportMessages } from "./transport-message-transform.js";
|
||||
import {
|
||||
assignTransportErrorDetails,
|
||||
failTransportStream,
|
||||
finalizeTransportStream,
|
||||
mergeTransportMetadata,
|
||||
sanitizeTransportPayloadText,
|
||||
} from "./transport-stream-shared.js";
|
||||
@@ -2801,15 +2803,9 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn {
|
||||
signal: options?.signal,
|
||||
emitReasoning,
|
||||
});
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
stream.push({ type: "done", reason: output.stopReason as never, message: output as never });
|
||||
stream.end();
|
||||
finalizeTransportStream({ stream, output, signal: options?.signal });
|
||||
} catch (error) {
|
||||
assignTransportErrorDetails(output, error, options?.signal);
|
||||
stream.push({ type: "error", reason: output.stopReason as never, error: output as never });
|
||||
stream.end();
|
||||
failTransportStream({ stream, output, signal: options?.signal, error });
|
||||
}
|
||||
})();
|
||||
return eventStream as unknown as ReturnType<StreamFn>;
|
||||
@@ -2976,7 +2972,6 @@ async function processOpenAICompletionsStream(
|
||||
currentBlock = null;
|
||||
flushPendingPostToolCallDeltas();
|
||||
}
|
||||
output.stopReason = "toolUse";
|
||||
recoveredDeepSeekToolCallIndex += 1;
|
||||
const block: ToolCallBlock = {
|
||||
type: "toolCall",
|
||||
@@ -3246,6 +3241,8 @@ async function processOpenAICompletionsStream(
|
||||
if (output.stopReason === "toolUse" && !hasToolCalls) {
|
||||
output.stopReason = "stop";
|
||||
}
|
||||
// Tool-call recovery is executable only after an explicit provider terminal.
|
||||
// EOF alone can mean transport truncation, even when the recovered call parses.
|
||||
if (sawStopFinishReason && output.stopReason === "stop" && hasToolCalls && !hasVisibleText) {
|
||||
output.stopReason = "toolUse";
|
||||
}
|
||||
|
||||
54
src/agents/openclaw-tools.gateway-caller-identity.test.ts
Normal file
54
src/agents/openclaw-tools.gateway-caller-identity.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Verifies plugin tools inherit the agent Gateway caller identity from tool assembly.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
observedIdentities: [] as Array<unknown>,
|
||||
}));
|
||||
|
||||
vi.mock("./openclaw-plugin-tools.js", () => ({
|
||||
resolveOpenClawPluginToolsForOptions: () => [
|
||||
{
|
||||
name: "synthetic_direct_cron_plugin",
|
||||
label: "Synthetic direct cron plugin",
|
||||
description: "Calls Gateway cron directly like plugin-owned reminder tools.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: async () => {
|
||||
const { getGatewayToolCallerIdentity } = await import("./tools/gateway-caller-context.js");
|
||||
mocks.observedIdentities.push(getGatewayToolCallerIdentity());
|
||||
return { content: [{ type: "text", text: "ok" }] };
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
|
||||
function requireTool(name: string) {
|
||||
const tool = createOpenClawTools({
|
||||
agentSessionKey: "agent:main:discord:channel:123",
|
||||
disableMessageTool: true,
|
||||
pluginToolAllowlist: [name],
|
||||
requesterAgentIdOverride: "main",
|
||||
wrapBeforeToolCallHook: false,
|
||||
}).find((candidate) => candidate.name === name);
|
||||
if (!tool?.execute) {
|
||||
throw new Error(`Expected executable tool ${name}`);
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
describe("createOpenClawTools Gateway caller identity", () => {
|
||||
it("wraps plugin tools so direct cron Gateway calls inherit the agent identity", async () => {
|
||||
mocks.observedIdentities.length = 0;
|
||||
|
||||
const tool = requireTool("synthetic_direct_cron_plugin");
|
||||
await tool.execute("tool-call-1", {});
|
||||
|
||||
expect(mocks.observedIdentities).toEqual([
|
||||
{
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:discord:channel:123",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ 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,
|
||||
@@ -576,10 +577,17 @@ export function createOpenClawTools(
|
||||
options?.recordToolPrepStage?.("openclaw-tools:plugin-tools");
|
||||
}
|
||||
|
||||
if (options?.wrapBeforeToolCallHook === false) {
|
||||
return allTools;
|
||||
}
|
||||
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);
|
||||
}
|
||||
const defaultHookContext: HookContext = {
|
||||
...(hookAgentId ? { agentId: hookAgentId } : {}),
|
||||
...(resolvedConfig ? { config: resolvedConfig } : {}),
|
||||
@@ -593,11 +601,13 @@ export function createOpenClawTools(
|
||||
...options?.beforeToolCallHookContext,
|
||||
};
|
||||
options?.recordToolPrepStage?.("openclaw-tools:tool-hooks");
|
||||
return allTools.map((tool) =>
|
||||
isToolWrappedWithBeforeToolCallHook(tool)
|
||||
? tool
|
||||
: wrapToolWithBeforeToolCallHook(tool, hookContext),
|
||||
);
|
||||
return allTools
|
||||
.map((tool) =>
|
||||
isToolWrappedWithBeforeToolCallHook(tool)
|
||||
? tool
|
||||
: wrapToolWithBeforeToolCallHook(tool, hookContext),
|
||||
)
|
||||
.map(wrapGatewayCallerIdentity);
|
||||
}
|
||||
|
||||
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 "../agent-tools.before-tool-call.js";
|
||||
import { copyBeforeToolCallHookMarker } from "../before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "../channel-tools.js";
|
||||
import {
|
||||
logProviderToolSchemaDiagnostics,
|
||||
|
||||
@@ -6,9 +6,13 @@ const { callGatewayToolMock } = vi.hoisted(() => ({
|
||||
callGatewayToolMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../agent-scope.js", () => ({
|
||||
resolveSessionAgentId: () => "agent-123",
|
||||
}));
|
||||
vi.mock("../agent-scope.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveSessionAgentId: actual.resolveSessionAgentId,
|
||||
};
|
||||
});
|
||||
|
||||
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: () => "agent-123",
|
||||
resolveSessionAgentId: actual.resolveSessionAgentId,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -182,7 +182,10 @@ 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({ selfRemoveOnlyJobId: "job-current" });
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "main",
|
||||
selfRemoveOnlyJobId: "job-current",
|
||||
});
|
||||
|
||||
await tool.execute("call-self-remove", {
|
||||
action: "remove",
|
||||
@@ -194,7 +197,10 @@ describe("cron tool", () => {
|
||||
});
|
||||
|
||||
it("denies scoped isolated cron runs from removing another job", async () => {
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "main",
|
||||
selfRemoveOnlyJobId: "job-current",
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-remove-other", {
|
||||
@@ -215,7 +221,10 @@ describe("cron tool", () => {
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
});
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "main",
|
||||
selfRemoveOnlyJobId: "job-current",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-self-runs", {
|
||||
action: "runs",
|
||||
@@ -238,7 +247,10 @@ 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({ selfRemoveOnlyJobId: "job-current" });
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "main",
|
||||
selfRemoveOnlyJobId: "job-current",
|
||||
});
|
||||
|
||||
await expect(tool.execute("call-runs-denied", args)).rejects.toThrow(
|
||||
"Cron tool is restricted to the current cron job.",
|
||||
@@ -281,7 +293,10 @@ 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({ selfRemoveOnlyJobId: "job-current" });
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "main",
|
||||
selfRemoveOnlyJobId: "job-current",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-get", {
|
||||
action: "get",
|
||||
@@ -329,7 +344,6 @@ describe("cron tool", () => {
|
||||
|
||||
const result = await tool.execute("call-list", {
|
||||
action: "list",
|
||||
agentId: "other-agent",
|
||||
includeDisabled: true,
|
||||
});
|
||||
|
||||
@@ -448,22 +462,44 @@ 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("prefers explicit cron list agent id over the requester session", async () => {
|
||||
it("rejects explicit cron list agent id outside the requester session", async () => {
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "agent:agent-123:telegram:direct:channing",
|
||||
});
|
||||
|
||||
await tool.execute("call-list-explicit", {
|
||||
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", {
|
||||
action: "list",
|
||||
agentId: "ops",
|
||||
agentId: "worker",
|
||||
includeDisabled: true,
|
||||
});
|
||||
|
||||
const params = expectSingleGatewayCallMethod("cron.list");
|
||||
expect(params).toEqual({ includeDisabled: true, compact: true, agentId: "ops" });
|
||||
expect(params).toEqual({
|
||||
includeDisabled: true,
|
||||
compact: true,
|
||||
agentId: "worker",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries cron.list without compact for older gateways", async () => {
|
||||
@@ -483,11 +519,18 @@ 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -744,7 +787,10 @@ 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 () => {
|
||||
@@ -755,7 +801,10 @@ 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 () => {
|
||||
@@ -766,7 +815,10 @@ 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 () => {
|
||||
@@ -794,18 +846,43 @@ describe("cron tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not default agentId when job.agentId is null", async () => {
|
||||
it("rejects null agentId on add from the scoped agent cron tool", async () => {
|
||||
const tool = createTestCronTool({ agentSessionKey: "main" });
|
||||
await tool.execute("call-null", {
|
||||
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", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "wake-up",
|
||||
name: "worker job",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
agentId: null,
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
agentId: "worker",
|
||||
},
|
||||
});
|
||||
|
||||
expect(readGatewayCall().params?.agentId).toBeNull();
|
||||
const params = expectSingleGatewayCallMethod("cron.add");
|
||||
expect(params).toMatchObject({
|
||||
name: "worker job",
|
||||
agentId: "worker",
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
});
|
||||
expect(params).not.toHaveProperty("callerScope");
|
||||
});
|
||||
|
||||
it("infers session agentId when job.agentId is omitted", async () => {
|
||||
@@ -828,6 +905,71 @@ 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", {
|
||||
@@ -1231,23 +1373,23 @@ describe("cron tool", () => {
|
||||
expect(text).not.toContain("Recent context:");
|
||||
});
|
||||
|
||||
it("preserves explicit agentId null on add", async () => {
|
||||
it("rejects explicit agentId null on add", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const tool = createTestCronTool({ agentSessionKey: "main" });
|
||||
await tool.execute("call6", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
agentId: null,
|
||||
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
||||
},
|
||||
});
|
||||
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");
|
||||
|
||||
const call = readGatewayCall();
|
||||
expect(call.method).toBe("cron.add");
|
||||
expect(call.params?.agentId).toBeNull();
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not infer delivery from raw session-key fragments without delivery context", async () => {
|
||||
@@ -1767,6 +1909,55 @@ 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,13 +5,14 @@
|
||||
*/
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { Type, type TSchema } from "typebox";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import { getRuntimeConfig, type OpenClawConfig } 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";
|
||||
@@ -45,6 +46,7 @@ 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";
|
||||
@@ -350,6 +352,11 @@ type CronToolOptions = {
|
||||
selfRemoveOnlyJobId?: string;
|
||||
};
|
||||
|
||||
type CronToolCallerScope = {
|
||||
kind: "agentTool";
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
export type CronCreatorToolAllowlistEntry =
|
||||
| string
|
||||
| {
|
||||
@@ -541,7 +548,9 @@ 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") {
|
||||
@@ -576,6 +585,70 @@ 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) {
|
||||
@@ -859,325 +932,360 @@ 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;
|
||||
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
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");
|
||||
}
|
||||
if (!selfRemoveOnlyJobId || cronListResultHasJob(result, selfRemoveOnlyJobId)) {
|
||||
shouldContinue = false;
|
||||
} else {
|
||||
const nextOffset = readCronListNextOffset(result, offset);
|
||||
if (nextOffset === undefined) {
|
||||
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;
|
||||
}
|
||||
if (!selfRemoveOnlyJobId || cronListResultHasJob(result, selfRemoveOnlyJobId)) {
|
||||
shouldContinue = false;
|
||||
} else {
|
||||
offset = nextOffset;
|
||||
const nextOffset = readCronListNextOffset(result, offset);
|
||||
if (nextOffset === undefined) {
|
||||
shouldContinue = false;
|
||||
} else {
|
||||
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 }));
|
||||
}
|
||||
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);
|
||||
const cfg = getRuntimeConfig();
|
||||
if (job && typeof job === "object") {
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
const resolvedSessionKey = opts?.agentSessionKey
|
||||
? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey })
|
||||
: undefined;
|
||||
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 sessionTarget = normalizeLowercaseStringOrEmpty(
|
||||
(job as { sessionTarget?: unknown }).sessionTarget,
|
||||
return jsonResult(
|
||||
selfRemoveOnlyJobId
|
||||
? filterCronListResultToJobId(result, selfRemoveOnlyJobId)
|
||||
: result,
|
||||
);
|
||||
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;
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")}`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "update": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
|
||||
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, {
|
||||
// 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
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`,
|
||||
creatorToolAllowlist: opts?.creatorToolAllowlist,
|
||||
gatewayOpts,
|
||||
callGateway,
|
||||
});
|
||||
return jsonResult(
|
||||
await callGateway("cron.update", gatewayOpts, {
|
||||
id,
|
||||
patch,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const agentId =
|
||||
explicitAgentId ??
|
||||
(explicitSessionKey ? agentIdFromExplicitSessionKey : inferredAgentId);
|
||||
return jsonResult(
|
||||
await callGateway(
|
||||
"wake",
|
||||
gatewayOpts,
|
||||
{
|
||||
mode,
|
||||
text,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(agentId ? { agentId } : {}),
|
||||
},
|
||||
{ expectFinal: false },
|
||||
),
|
||||
);
|
||||
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`,
|
||||
);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
return setToolTerminalPresentation(tool, formatCronTerminalPresentation);
|
||||
|
||||
43
src/agents/tools/gateway-caller-context.test.ts
Normal file
43
src/agents/tools/gateway-caller-context.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
53
src/agents/tools/gateway-caller-context.ts
Normal file
53
src/agents/tools/gateway-caller-context.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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,6 +4,7 @@ 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(() => ({
|
||||
@@ -318,6 +319,136 @@ 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("explains stale gateway cron connection metadata rejections", async () => {
|
||||
mocks.callGateway.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"invalid connect params: at /auth: unexpected property 'agentRuntimeIdentityToken'",
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
withGatewayToolCallerIdentity(
|
||||
{ agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" },
|
||||
async () => {
|
||||
await callGatewayTool("cron.remove", {}, { id: "job-1" });
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"The running Gateway is from an older OpenClaw build and rejected current agent cron connection metadata. Restart the Gateway with `openclaw gateway restart`, then retry.",
|
||||
);
|
||||
|
||||
const call = capturedGatewayCall();
|
||||
expect(call.agentRuntimeIdentityToken).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("explains fail-closed stale gateway cron identity rejections", async () => {
|
||||
mocks.callGateway.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"gateway rejected required agent runtime identity auth field; refusing to retry without it",
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
withGatewayToolCallerIdentity(
|
||||
{ agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" },
|
||||
async () => {
|
||||
await callGatewayTool("cron.remove", {}, { id: "job-1" });
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"The running Gateway is from an older OpenClaw build and rejected current agent cron connection metadata. Restart the Gateway with `openclaw gateway restart`, then retry.",
|
||||
);
|
||||
|
||||
const call = capturedGatewayCall();
|
||||
expect(call.agentRuntimeIdentityToken).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("does not rewrite stale gateway validation errors for unscoped cron calls", async () => {
|
||||
const originalError = new Error(
|
||||
"invalid connect params: at /auth: unexpected property 'agentRuntimeIdentityToken'",
|
||||
);
|
||||
mocks.callGateway.mockRejectedValueOnce(originalError);
|
||||
|
||||
await expect(callGatewayTool("cron.remove", {}, { id: "job-1" })).rejects.toBe(originalError);
|
||||
});
|
||||
|
||||
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,6 +13,7 @@ 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 {
|
||||
@@ -27,6 +28,7 @@ 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 = {
|
||||
@@ -208,6 +210,16 @@ 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;
|
||||
@@ -263,6 +275,52 @@ 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);
|
||||
}
|
||||
|
||||
function isStaleGatewayAgentRuntimeIdentityRejection(error: unknown): boolean {
|
||||
const message = formatErrorMessage(error);
|
||||
if (
|
||||
message.includes(
|
||||
"gateway rejected required agent runtime identity auth field; refusing to retry without it",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
message.includes("invalid connect params") &&
|
||||
message.includes("/auth") &&
|
||||
message.includes("unexpected property 'agentRuntimeIdentityToken'")
|
||||
);
|
||||
}
|
||||
|
||||
function staleGatewayAgentRuntimeIdentityError(cause: unknown): Error {
|
||||
return new Error(
|
||||
[
|
||||
"The running Gateway is from an older OpenClaw build and rejected current agent cron connection metadata.",
|
||||
"Restart the Gateway with `openclaw gateway restart`, then retry.",
|
||||
].join(" "),
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a gateway method as the agent-tool backend client with least-privilege scopes.
|
||||
*/
|
||||
@@ -281,23 +339,36 @@ 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,
|
||||
target: gateway.target,
|
||||
});
|
||||
return await callGateway<T>({
|
||||
url: gateway.url,
|
||||
token: gateway.token,
|
||||
method,
|
||||
params,
|
||||
timeoutMs: gateway.timeoutMs,
|
||||
expectFinal: extra?.expectFinal,
|
||||
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
clientDisplayName: "agent",
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
...(approvalRuntimeToken ? { approvalRuntimeToken } : {}),
|
||||
...(deviceIdentity ? { deviceIdentity } : {}),
|
||||
scopes,
|
||||
});
|
||||
try {
|
||||
return await callGateway<T>({
|
||||
url: gateway.url,
|
||||
token: gateway.token,
|
||||
method,
|
||||
params,
|
||||
timeoutMs: gateway.timeoutMs,
|
||||
expectFinal: extra?.expectFinal,
|
||||
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
clientDisplayName: "agent",
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
...(approvalRuntimeToken ? { approvalRuntimeToken } : {}),
|
||||
...(agentRuntimeIdentityToken ? { agentRuntimeIdentityToken } : {}),
|
||||
...(deviceIdentity ? { deviceIdentity } : {}),
|
||||
scopes,
|
||||
});
|
||||
} catch (error) {
|
||||
if (agentRuntimeIdentityToken && isStaleGatewayAgentRuntimeIdentityRejection(error)) {
|
||||
throw staleGatewayAgentRuntimeIdentityError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1622,6 +1622,33 @@ describe("runPreparedReply media-only handling", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rebinds a queued pre-dispatch reply operation after session rollover", async () => {
|
||||
const operation = createReplyOperation({
|
||||
sessionId: "session-before-rollover",
|
||||
sessionKey: "session-key",
|
||||
resetTriggered: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runPreparedReply(
|
||||
baseParams({
|
||||
isNewSession: true,
|
||||
sessionId: "session-after-rollover",
|
||||
opts: { replyOperation: operation } as never,
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({ text: "ok" });
|
||||
|
||||
const call = requireLastRunReplyAgentCall();
|
||||
expect(operation.sessionId).toBe("session-after-rollover");
|
||||
expect(call.replyOperation).toBe(operation);
|
||||
expect(call.followupRun.run.sessionId).toBe("session-after-rollover");
|
||||
} finally {
|
||||
operation.complete();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not interrupt its provided pre-dispatch reply operation for reset turns", async () => {
|
||||
const queueSettings = await import("./queue/settings-runtime.js");
|
||||
const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js");
|
||||
|
||||
@@ -937,6 +937,18 @@ export async function runPreparedReply(
|
||||
}
|
||||
const internalOpts = opts as InternalGetReplyOptions | undefined;
|
||||
const providedReplyOperation = internalOpts?.replyOperation;
|
||||
if (
|
||||
providedReplyOperation !== undefined &&
|
||||
providedReplyOperation.result === null &&
|
||||
providedReplyOperation.phase === "queued" &&
|
||||
sessionId !== undefined &&
|
||||
sessionId !== providedReplyOperation.sessionId
|
||||
) {
|
||||
// Dispatch reserves a queued operation before session init. If stale init
|
||||
// rotates the session, move the reservation so later steer/abort paths
|
||||
// target the session that will actually run.
|
||||
providedReplyOperation.updateSessionId(sessionId);
|
||||
}
|
||||
const isOwnPreDispatchOperationSession = (candidateSessionId: string | undefined): boolean =>
|
||||
providedReplyOperation !== undefined &&
|
||||
providedReplyOperation.result === null &&
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createSessionConversationTestRegistry } from "../../test-utils/session-
|
||||
import { drainFormattedSystemEvents } from "./session-updates.js";
|
||||
import { persistSessionUsageUpdate } from "./session-usage.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
import { replyRunRegistry } from "./reply-run-registry.js";
|
||||
|
||||
const sessionForkMocks = vi.hoisted(() => ({
|
||||
forkSessionFromParent: vi.fn(),
|
||||
@@ -3709,6 +3710,177 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
|
||||
}
|
||||
});
|
||||
|
||||
it("defers implicit daily rollover while the same session has an active run", async () => {
|
||||
vi.useFakeTimers();
|
||||
const existingSessionId = "active-stale-session";
|
||||
let operation: ReturnType<typeof replyRunRegistry.begin> | undefined;
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
||||
const storePath = await createStorePath("openclaw-active-stale-archive-");
|
||||
const sessionKey = "agent:main:telegram:dm:active-stale-user";
|
||||
const transcriptPath = path.join(path.dirname(storePath), `${existingSessionId}.jsonl`);
|
||||
const sessionStartedAt = new Date(2026, 0, 18, 3, 0, 0).getTime();
|
||||
|
||||
await writeSessionStoreFast(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: sessionStartedAt,
|
||||
sessionStartedAt,
|
||||
},
|
||||
});
|
||||
await fs.writeFile(transcriptPath, '{"type":"message"}\n', "utf8");
|
||||
operation = replyRunRegistry.begin({
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
resetTriggered: false,
|
||||
});
|
||||
operation.setPhase("running");
|
||||
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "hello while active",
|
||||
RawBody: "hello while active",
|
||||
CommandBody: "hello while active",
|
||||
From: "user-active-stale",
|
||||
To: "bot",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(false);
|
||||
expect(result.resetTriggered).toBe(false);
|
||||
expect(result.sessionId).toBe(existingSessionId);
|
||||
expect(result.previousSessionEntry).toBeUndefined();
|
||||
expect(result.sessionEntry.sessionStartedAt).toBe(sessionStartedAt);
|
||||
expect(await fs.stat(transcriptPath).catch(() => null)).not.toBeNull();
|
||||
const archived = (await fs.readdir(path.dirname(storePath))).filter((entry) =>
|
||||
entry.startsWith(`${existingSessionId}.jsonl.reset.`),
|
||||
);
|
||||
expect(archived).toHaveLength(0);
|
||||
} finally {
|
||||
operation?.complete();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not defer stale archival for the current turn's queued reservation", async () => {
|
||||
vi.useFakeTimers();
|
||||
let operation: ReturnType<typeof replyRunRegistry.begin> | undefined;
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
||||
const storePath = await createStorePath("openclaw-queued-stale-archive-");
|
||||
const sessionKey = "agent:main:telegram:dm:queued-stale-user";
|
||||
const existingSessionId = "queued-stale-session";
|
||||
const transcriptPath = path.join(path.dirname(storePath), `${existingSessionId}.jsonl`);
|
||||
|
||||
await writeSessionStoreFast(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||
},
|
||||
});
|
||||
await fs.writeFile(transcriptPath, '{"type":"message"}\n', "utf8");
|
||||
operation = replyRunRegistry.begin({
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
resetTriggered: false,
|
||||
});
|
||||
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "hello after boundary",
|
||||
RawBody: "hello after boundary",
|
||||
CommandBody: "hello after boundary",
|
||||
From: "user-queued-stale",
|
||||
To: "bot",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(operation.phase).toBe("queued");
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.resetTriggered).toBe(false);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
expect(result.previousSessionEntry?.sessionId).toBe(existingSessionId);
|
||||
expect(await fs.stat(transcriptPath).catch(() => null)).toBeNull();
|
||||
const archived = (await fs.readdir(path.dirname(storePath))).filter((entry) =>
|
||||
entry.startsWith(`${existingSessionId}.jsonl.reset.`),
|
||||
);
|
||||
expect(archived).toHaveLength(1);
|
||||
} finally {
|
||||
operation?.complete();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not defer stale archival for a different active session id", async () => {
|
||||
vi.useFakeTimers();
|
||||
let operation: ReturnType<typeof replyRunRegistry.begin> | undefined;
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
||||
const storePath = await createStorePath("openclaw-active-other-stale-archive-");
|
||||
const sessionKey = "agent:main:telegram:dm:active-other-stale-user";
|
||||
const existingSessionId = "inactive-stale-session";
|
||||
const transcriptPath = path.join(path.dirname(storePath), `${existingSessionId}.jsonl`);
|
||||
|
||||
await writeSessionStoreFast(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||
},
|
||||
});
|
||||
await fs.writeFile(transcriptPath, '{"type":"message"}\n', "utf8");
|
||||
operation = replyRunRegistry.begin({
|
||||
sessionKey,
|
||||
sessionId: "different-active-session",
|
||||
resetTriggered: false,
|
||||
});
|
||||
operation.setPhase("running");
|
||||
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "hello after boundary",
|
||||
RawBody: "hello after boundary",
|
||||
CommandBody: "hello after boundary",
|
||||
From: "user-active-other-stale",
|
||||
To: "bot",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.resetTriggered).toBe(false);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
expect(result.previousSessionEntry?.sessionId).toBe(existingSessionId);
|
||||
expect(await fs.stat(transcriptPath).catch(() => null)).toBeNull();
|
||||
const archived = (await fs.readdir(path.dirname(storePath))).filter((entry) =>
|
||||
entry.startsWith(`${existingSessionId}.jsonl.reset.`),
|
||||
);
|
||||
expect(archived).toHaveLength(1);
|
||||
} finally {
|
||||
operation?.complete();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps provider-owned CLI sessions on implicit daily reset boundaries", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
resolveLastChannelRaw,
|
||||
resolveLastToRaw,
|
||||
} from "./session-delivery.js";
|
||||
import { replyRunRegistry } from "./reply-run-registry.js";
|
||||
import {
|
||||
createReplySessionEntryHandle,
|
||||
type ReplySessionEntryHandle,
|
||||
@@ -543,11 +544,25 @@ async function initSessionStateAttemptLocked(
|
||||
(entryFreshness?.fresh ?? false) ||
|
||||
(softResetAllowed && canReuseExistingEntry)) &&
|
||||
!terminalMainTranscriptNewerThanRegistry);
|
||||
const activeReplyOperation = replyRunRegistry.get(sessionKey);
|
||||
const deferImplicitRolloverForActiveRun =
|
||||
!resetTriggered &&
|
||||
!freshEntry &&
|
||||
canReuseExistingEntry &&
|
||||
entryFreshness?.fresh === false &&
|
||||
entryFreshness.staleReason != null &&
|
||||
activeReplyOperation?.phase !== "queued" &&
|
||||
activeReplyOperation?.sessionId === entry?.sessionId;
|
||||
// Implicit daily/idle rollover must not rename a transcript while that exact
|
||||
// session's active writer is still running. Admission will steer/wait/queue;
|
||||
// queued pre-dispatch reservations still let the current turn roll over.
|
||||
const effectiveFreshEntry = deferImplicitRolloverForActiveRun ? true : freshEntry;
|
||||
// Capture the current session entry before any reset so its transcript can be
|
||||
// archived afterward. We need to do this for both explicit resets (/new, /reset)
|
||||
// and for scheduled/daily resets where the session has become stale (!freshEntry).
|
||||
// Without this, daily-reset transcripts are left as orphaned files on disk (#35481).
|
||||
const previousSessionEntry = (resetTriggered || !freshEntry) && entry ? { ...entry } : undefined;
|
||||
const previousSessionEntry =
|
||||
(resetTriggered || !effectiveFreshEntry) && entry ? { ...entry } : undefined;
|
||||
const previousSessionEndReason = resetTriggered
|
||||
? resolveExplicitSessionEndReason(matchedResetTriggerLower)
|
||||
: resolveStaleSessionEndReason({
|
||||
@@ -562,7 +577,7 @@ async function initSessionStateAttemptLocked(
|
||||
clearSessionResetRuntimeState([sessionKey, previousSessionEntry.sessionId]);
|
||||
}
|
||||
|
||||
if (!isNewSession && freshEntry && canReuseExistingEntry) {
|
||||
if (!isNewSession && effectiveFreshEntry && canReuseExistingEntry) {
|
||||
sessionId = entry.sessionId;
|
||||
systemSent = entry.systemSent ?? false;
|
||||
abortedLastRun = entry.abortedLastRun ?? false;
|
||||
@@ -633,7 +648,7 @@ async function initSessionStateAttemptLocked(
|
||||
}
|
||||
}
|
||||
|
||||
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
|
||||
const baseEntry = !isNewSession && effectiveFreshEntry ? entry : undefined;
|
||||
const usageFamilyKey = previousSessionEntry
|
||||
? (previousSessionEntry.usageFamilyKey ?? sessionKey)
|
||||
: baseEntry?.usageFamilyKey;
|
||||
|
||||
96
src/gateway/agent-runtime-identity-token.test.ts
Normal file
96
src/gateway/agent-runtime-identity-token.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
121
src/gateway/agent-runtime-identity-token.ts
Normal file
121
src/gateway/agent-runtime-identity-token.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -72,6 +72,7 @@ let lastClientOptions: {
|
||||
clientDisplayName?: string;
|
||||
mode?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
scopes?: string[];
|
||||
deviceIdentity?: unknown;
|
||||
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
|
||||
@@ -170,6 +171,7 @@ vi.mock("./client.js", () => ({
|
||||
clientDisplayName?: string;
|
||||
mode?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
scopes?: string[];
|
||||
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
|
||||
onClose?: (code: number, reason: string, info?: StubGatewayClientCloseInfo) => void;
|
||||
@@ -222,6 +224,8 @@ class StubGatewayClient {
|
||||
clientName?: string;
|
||||
clientDisplayName?: string;
|
||||
mode?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
scopes?: string[];
|
||||
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
|
||||
onClose?: (code: number, reason: string, info?: StubGatewayClientCloseInfo) => void;
|
||||
@@ -1512,6 +1516,27 @@ describe("callGateway error details", () => {
|
||||
expect(lastRequestOptions).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces agent runtime identity connect request errors", async () => {
|
||||
startMode = "connect-error";
|
||||
connectError = new Error(
|
||||
"gateway rejected required agent runtime identity auth field; refusing to retry without it",
|
||||
);
|
||||
setLocalLoopbackGatewayConfig();
|
||||
|
||||
await expect(
|
||||
callGateway({
|
||||
method: "cron.remove",
|
||||
token: "explicit-token",
|
||||
agentRuntimeIdentityToken: "identity-token",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"gateway rejected required agent runtime identity auth field; refusing to retry without it",
|
||||
);
|
||||
|
||||
expect(lastClientOptions?.agentRuntimeIdentityToken).toBe("identity-token");
|
||||
expect(lastRequestOptions).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces stored device auth handshake failures for credential fallback", async () => {
|
||||
startMode = "connect-error";
|
||||
connectError = Object.assign(new Error("unauthorized: device token mismatch"), {
|
||||
|
||||
@@ -85,6 +85,7 @@ type CallGatewayBaseOptions = {
|
||||
platform?: string;
|
||||
mode?: GatewayClientMode;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
useStoredDeviceAuth?: boolean;
|
||||
requiredStoredDeviceAuthScopes?: OperatorScope[];
|
||||
requireLocalBackendSharedAuth?: boolean;
|
||||
@@ -882,6 +883,12 @@ function ensureGatewaySupportsRequiredMethods(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function isRequiredAgentRuntimeIdentityConnectError(err: Error): boolean {
|
||||
return err.message.includes(
|
||||
"gateway rejected required agent runtime identity auth field; refusing to retry without it",
|
||||
);
|
||||
}
|
||||
|
||||
async function executeGatewayRequestWithScopes<T>(params: {
|
||||
opts: CallGatewayBaseOptions;
|
||||
scopes: OperatorScope[] | undefined;
|
||||
@@ -989,6 +996,9 @@ 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,
|
||||
@@ -1044,8 +1054,12 @@ async function executeGatewayRequestWithScopes<T>(params: {
|
||||
},
|
||||
onConnectError: (err) => {
|
||||
const isGatewayClientRequestError = err.name === "GatewayClientRequestError";
|
||||
const isAgentRuntimeIdentityConnectError =
|
||||
Boolean(opts.agentRuntimeIdentityToken) &&
|
||||
isRequiredAgentRuntimeIdentityConnectError(err);
|
||||
const shouldSurface =
|
||||
isGatewayConnectAssemblyError(err) ||
|
||||
isAgentRuntimeIdentityConnectError ||
|
||||
(surfaceGatewayClientRequestErrors && isGatewayClientRequestError);
|
||||
if (settled || !shouldSurface) {
|
||||
return;
|
||||
@@ -1201,7 +1215,9 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
|
||||
connectionDetails,
|
||||
deviceIdentity,
|
||||
surfaceGatewayClientRequestErrors:
|
||||
useStoredDeviceAuth || opts.requireLocalBackendSharedAuth === true,
|
||||
useStoredDeviceAuth ||
|
||||
opts.requireLocalBackendSharedAuth === true ||
|
||||
Boolean(opts.agentRuntimeIdentityToken),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1174,6 +1174,7 @@ describe("GatewayClient connect auth payload", () => {
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -1470,6 +1471,44 @@ 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,6 +131,7 @@ export type GatewayClientOptions = {
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
instanceId?: string;
|
||||
clientName?: GatewayClientName;
|
||||
clientDisplayName?: string;
|
||||
|
||||
@@ -23,6 +23,10 @@ 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";
|
||||
@@ -32,13 +36,22 @@ import {
|
||||
resolveTargetPrefixedChannel,
|
||||
validateTargetProviderPrefix,
|
||||
} from "../../infra/outbound/channel-target-prefix.js";
|
||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
isSubagentSessionKey,
|
||||
normalizeAgentId,
|
||||
} from "../../routing/session-key.js";
|
||||
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
|
||||
import {
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import type { GatewayClient, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
type CronCallerScope = {
|
||||
kind: "agentTool";
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
type CronJobIdParams = { id?: string; jobId?: string };
|
||||
|
||||
@@ -55,6 +68,13 @@ 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,
|
||||
@@ -66,6 +86,171 @@ 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);
|
||||
}
|
||||
@@ -350,7 +535,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"cron.list": async ({ params, respond, context }) => {
|
||||
"cron.list": async ({ params, respond, context, client }) => {
|
||||
if (!validateCronListParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
@@ -375,7 +560,13 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
agentId?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
const page = await context.cron.listPage({
|
||||
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 = {
|
||||
includeDisabled: p.includeDisabled,
|
||||
limit: p.limit,
|
||||
offset: p.offset,
|
||||
@@ -385,8 +576,15 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
lastRunStatus: p.lastRunStatus,
|
||||
sortBy: p.sortBy,
|
||||
sortDir: p.sortDir,
|
||||
agentId: p.agentId,
|
||||
});
|
||||
agentId: callerScope?.agentId ?? p.agentId,
|
||||
};
|
||||
const page = callerScope
|
||||
? await listCronPageForCallerScope({
|
||||
callerScope,
|
||||
context,
|
||||
options: listOptions,
|
||||
})
|
||||
: await context.cron.listPage(listOptions);
|
||||
if (p.compact === true) {
|
||||
respond(true, { ...page, jobs: page.jobs.map(compactCronListJob) }, undefined);
|
||||
return;
|
||||
@@ -413,7 +611,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
const status = await context.cron.status();
|
||||
respond(true, status, undefined);
|
||||
},
|
||||
"cron.get": async ({ params, respond, context }) => {
|
||||
"cron.get": async ({ params, respond, context, client }) => {
|
||||
if (!validateCronGetParams(params)) {
|
||||
respondInvalidCronParams(
|
||||
respond,
|
||||
@@ -427,8 +625,16 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
respondMissingCronJobId(respond, "cron.get");
|
||||
return;
|
||||
}
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const job = await context.cron.readJob(jobId);
|
||||
if (!job) {
|
||||
if (
|
||||
!job ||
|
||||
!cronJobMatchesCallerScope({
|
||||
job,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
})
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -438,7 +644,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
respond(true, job, undefined);
|
||||
},
|
||||
"cron.add": async ({ params, respond, context }) => {
|
||||
"cron.add": async ({ params, respond, context, client }) => {
|
||||
const sessionKey =
|
||||
typeof (params as { sessionKey?: unknown } | null)?.sessionKey === "string"
|
||||
? (params as { sessionKey: string }).sessionKey
|
||||
@@ -461,7 +667,8 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!validateCronAddParams(normalized)) {
|
||||
const candidate = normalized;
|
||||
if (!validateCronAddParams(candidate)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -472,8 +679,19 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const jobCreate = normalized as unknown as CronJobCreate;
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const jobCreate = applyCronCreateCallerScopeDefault(candidate as CronJobCreate, callerScope);
|
||||
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(
|
||||
@@ -520,7 +738,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 }) => {
|
||||
"cron.update": async ({ params, respond, context, client }) => {
|
||||
let normalizedPatch: ReturnType<typeof normalizeCronJobPatch>;
|
||||
try {
|
||||
const rawPatch = (params as { patch?: unknown } | null)?.patch;
|
||||
@@ -561,6 +779,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
jobId?: string;
|
||||
patch: Record<string, unknown>;
|
||||
};
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const jobId = p.id ?? p.jobId;
|
||||
if (!jobId) {
|
||||
respond(
|
||||
@@ -573,10 +792,25 @@ 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) {
|
||||
if (
|
||||
!currentJob ||
|
||||
!cronJobMatchesCallerScope({
|
||||
job: currentJob,
|
||||
callerScope,
|
||||
defaultAgentId: context.cron.getDefaultAgentId(),
|
||||
})
|
||||
) {
|
||||
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) {
|
||||
@@ -630,7 +864,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
context.logGateway.info("cron: job updated", { jobId });
|
||||
respond(true, job, undefined);
|
||||
},
|
||||
"cron.remove": async ({ params, respond, context }) => {
|
||||
"cron.remove": async ({ params, respond, context, client }) => {
|
||||
if (!validateCronRemoveParams(params)) {
|
||||
respondInvalidCronParams(
|
||||
respond,
|
||||
@@ -644,6 +878,23 @@ 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(
|
||||
@@ -656,7 +907,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
context.logGateway.info("cron: job removed", { jobId });
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"cron.run": async ({ params, respond, context }) => {
|
||||
"cron.run": async ({ params, respond, context, client }) => {
|
||||
if (!validateCronRunParams(params)) {
|
||||
respondInvalidCronParams(
|
||||
respond,
|
||||
@@ -666,11 +917,24 @@ 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");
|
||||
@@ -687,7 +951,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"cron.runs": async ({ params, respond, context }) => {
|
||||
"cron.runs": async ({ params, respond, context, client }) => {
|
||||
if (!validateCronRunsParams(params)) {
|
||||
respondInvalidCronParams(
|
||||
respond,
|
||||
@@ -697,6 +961,7 @@ 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");
|
||||
@@ -705,6 +970,10 @@ 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
|
||||
@@ -721,7 +990,19 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
try {
|
||||
const jobs = await context.cron.list({ includeDisabled: true });
|
||||
const matchedJob = jobs.find((job) => job.id === jobId);
|
||||
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 jobNameById =
|
||||
matchedJob && typeof matchedJob.name === "string"
|
||||
? { [jobId as string]: matchedJob.name }
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import type { GatewayClient } from "./types.js";
|
||||
|
||||
const getRuntimeConfig = vi.hoisted(() =>
|
||||
vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig),
|
||||
@@ -80,7 +81,8 @@ function setCronValidationTestRegistry(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function createCronContext(currentJob?: CronJob) {
|
||||
function createCronContext(currentJobs?: CronJob | CronJob[]) {
|
||||
const jobs = currentJobs ? (Array.isArray(currentJobs) ? currentJobs : [currentJobs]) : [];
|
||||
return {
|
||||
cron: {
|
||||
add: vi.fn(async () => ({ id: "cron-1" })),
|
||||
@@ -88,9 +90,30 @@ function createCronContext(currentJob?: 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(() => currentJob),
|
||||
getJob: vi.fn((id: string) => jobs.find((job) => job.id === id)),
|
||||
wake: vi.fn(() => ({ ok: true }) as const),
|
||||
readJob: vi.fn(async (id: string) => (id === currentJob?.id ? currentJob : undefined)),
|
||||
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,
|
||||
};
|
||||
}),
|
||||
},
|
||||
logGateway: {
|
||||
info: vi.fn(),
|
||||
@@ -104,7 +127,11 @@ type CronMethod = keyof typeof cronHandlers;
|
||||
async function invokeCron(
|
||||
method: CronMethod,
|
||||
params: Record<string, unknown>,
|
||||
options: { currentJob?: CronJob; context?: ReturnType<typeof createCronContext> } = {},
|
||||
options: {
|
||||
currentJob?: CronJob;
|
||||
context?: ReturnType<typeof createCronContext>;
|
||||
client?: GatewayClient;
|
||||
} = {},
|
||||
) {
|
||||
const context = options.context ?? createCronContext(options.currentJob);
|
||||
const respond = vi.fn();
|
||||
@@ -113,22 +140,33 @@ async function invokeCron(
|
||||
params: params as never,
|
||||
respond: respond as never,
|
||||
context: context as never,
|
||||
client: null,
|
||||
client: options.client ?? null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return { context, respond };
|
||||
}
|
||||
|
||||
async function invokeCronAdd(params: Record<string, unknown>) {
|
||||
return await invokeCron("cron.add", params);
|
||||
async function invokeCronAdd(
|
||||
params: Record<string, unknown>,
|
||||
options?: { client?: GatewayClient },
|
||||
) {
|
||||
return await invokeCron("cron.add", params, options);
|
||||
}
|
||||
|
||||
async function invokeCronGet(params: Record<string, unknown>, currentJob?: CronJob) {
|
||||
return await invokeCron("cron.get", params, { currentJob });
|
||||
async function invokeCronGet(
|
||||
params: Record<string, unknown>,
|
||||
currentJob?: CronJob,
|
||||
options?: { client?: GatewayClient },
|
||||
) {
|
||||
return await invokeCron("cron.get", params, { currentJob, ...options });
|
||||
}
|
||||
|
||||
async function invokeCronUpdate(params: Record<string, unknown>, currentJob?: CronJob) {
|
||||
return await invokeCron("cron.update", params, { currentJob });
|
||||
async function invokeCronUpdate(
|
||||
params: Record<string, unknown>,
|
||||
currentJob?: CronJob,
|
||||
options?: { client?: GatewayClient },
|
||||
) {
|
||||
return await invokeCron("cron.update", params, { currentJob, ...options });
|
||||
}
|
||||
|
||||
async function invokeCronUpdateDelivery(
|
||||
@@ -146,13 +184,13 @@ async function invokeCronUpdateDelivery(
|
||||
|
||||
async function invokeCronRemove(
|
||||
params: Record<string, unknown>,
|
||||
options?: { removeResult?: { ok: boolean; removed: boolean } },
|
||||
options?: { removeResult?: { ok: boolean; removed: boolean }; client?: GatewayClient },
|
||||
) {
|
||||
const context = createCronContext();
|
||||
if (options?.removeResult) {
|
||||
context.cron.remove.mockResolvedValueOnce(options.removeResult);
|
||||
}
|
||||
return await invokeCron("cron.remove", params, { context });
|
||||
return await invokeCron("cron.remove", params, { context, client: options?.client });
|
||||
}
|
||||
|
||||
async function invokeWake(params: Record<string, unknown>) {
|
||||
@@ -176,6 +214,19 @@ 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",
|
||||
@@ -392,6 +443,35 @@ 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" });
|
||||
|
||||
@@ -401,6 +481,46 @@ 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" });
|
||||
|
||||
@@ -410,6 +530,150 @@ 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());
|
||||
|
||||
@@ -440,6 +704,84 @@ 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(
|
||||
{
|
||||
@@ -1071,9 +1413,74 @@ 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: "unknown cron job id: missing",
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -46,6 +47,7 @@ export type GatewayClient = {
|
||||
internal?: {
|
||||
allowModelOverride?: boolean;
|
||||
approvalRuntime?: boolean;
|
||||
agentRuntimeIdentity?: AgentRuntimeIdentity;
|
||||
pluginRuntimeOwnerId?: string;
|
||||
agentRunTracking?: "plugin_subagent";
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ type HandshakeConnectAuth = {
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
};
|
||||
|
||||
type DeviceTokenCandidateSource = "explicit-device-token" | "shared-token-fallback";
|
||||
|
||||
@@ -40,6 +40,7 @@ type HandshakeConnectAuth = {
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
approvalRuntimeToken?: string;
|
||||
agentRuntimeIdentityToken?: string;
|
||||
};
|
||||
|
||||
function resolveBrowserOriginRateLimitKey(requestOrigin?: string): string {
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -697,6 +698,134 @@ 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,6 +105,7 @@ 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";
|
||||
@@ -1865,6 +1866,49 @@ 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,
|
||||
@@ -1875,7 +1919,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration,
|
||||
presenceKey,
|
||||
clientIp: reportedClientIp,
|
||||
...(isTrustedApprovalRuntime ? { internal: { approvalRuntime: true } } : {}),
|
||||
...(internal ? { internal } : {}),
|
||||
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
|
||||
...(Object.keys(pluginNodeCapabilitySurfaces).length > 0
|
||||
? { pluginNodeCapabilitySurfaces }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,7 @@ export type GatewayWsClient = PluginNodeCapabilityClient & {
|
||||
clientIp?: string;
|
||||
internal?: {
|
||||
approvalRuntime?: boolean;
|
||||
agentRuntimeIdentity?: AgentRuntimeIdentity;
|
||||
};
|
||||
canvasHostUrl?: string;
|
||||
canvasCapability?: string;
|
||||
|
||||
@@ -116,6 +116,8 @@ 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"),
|
||||
|
||||
@@ -136,6 +136,42 @@ describe("consumeGoogleGenerateContentStream", () => {
|
||||
expect(output.usage.cost.total).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves MAX_TOKENS when the partial response contains a function call", async () => {
|
||||
const output = createOutput();
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const terminalReason = (async () => {
|
||||
for await (const event of stream) {
|
||||
if (event.type === "done") {
|
||||
return event.reason;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
await consumeGoogleGenerateContentStream({
|
||||
chunks: chunks([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ functionCall: { name: "lookup", args: { query: "cats" } } }],
|
||||
},
|
||||
finishReason: FinishReason.MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse,
|
||||
]),
|
||||
model,
|
||||
output,
|
||||
stream,
|
||||
nextToolCallId: (name) => `generated-${name}`,
|
||||
});
|
||||
|
||||
expect(await terminalReason).toBe("length");
|
||||
expect(output.stopReason).toBe("length");
|
||||
expect(output.content).toEqual([expect.objectContaining({ type: "toolCall", name: "lookup" })]);
|
||||
});
|
||||
|
||||
it("generates a new id when Google repeats a streamed tool-call id", async () => {
|
||||
const output = createOutput();
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
@@ -875,7 +875,12 @@ export async function consumeGoogleGenerateContentStream<T extends GoogleApiType
|
||||
|
||||
if (candidate?.finishReason) {
|
||||
params.output.stopReason = mapStopReason(candidate.finishReason);
|
||||
if (params.output.content.some((block) => block.type === "toolCall")) {
|
||||
// MAX_TOKENS can leave a complete-looking partial call. Only a normal
|
||||
// Google stop may promote parsed calls into an executable tool-use turn.
|
||||
if (
|
||||
params.output.stopReason === "stop" &&
|
||||
params.output.content.some((block) => block.type === "toolCall")
|
||||
) {
|
||||
params.output.stopReason = "toolUse";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
// OpenClaw MCP tools tests cover core tool server startup and registration.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveOpenClawToolsForMcp } from "./openclaw-tools-serve.js";
|
||||
import {
|
||||
OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
|
||||
resolveOpenClawToolsForMcp,
|
||||
resolveOpenClawToolsMcpAgentSessionKey,
|
||||
} 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());
|
||||
const handlers = createPluginToolsMcpHandlers(
|
||||
resolveOpenClawToolsForMcp({ agentSessionKey: "agent:worker:main" }),
|
||||
);
|
||||
|
||||
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,8 +11,26 @@ import { createCronTool } from "../agents/tools/cron-tool.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { connectToolsMcpServerToStdio, createToolsMcpServer } from "./tools-stdio-server.js";
|
||||
|
||||
export function resolveOpenClawToolsForMcp(): AnyAgentTool[] {
|
||||
return [createCronTool({ creatorToolAllowlist: [{ name: "cron" }] })];
|
||||
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" }] })];
|
||||
}
|
||||
|
||||
function createOpenClawToolsMcpServer(
|
||||
|
||||
@@ -385,7 +385,7 @@ describe("createPlainTextToolCallCompatWrapper", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("promotes complete under-cap text tool calls for non-stop terminal reasons", async () => {
|
||||
it("does not promote complete-looking text tool calls after a length stop", async () => {
|
||||
const rawToolText = '[tool:read] {"path":"/tmp/file.txt"}';
|
||||
const baseStreamFn: StreamFn = () =>
|
||||
createEventStream([
|
||||
@@ -410,14 +410,13 @@ describe("createPlainTextToolCallCompatWrapper", () => {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events.map((event) => (event as { type?: string }).type)).toEqual([
|
||||
"toolcall_start",
|
||||
"toolcall_delta",
|
||||
"done",
|
||||
]);
|
||||
const done = events.at(-1) as { reason?: unknown; message?: { stopReason?: unknown } };
|
||||
expect(done.reason).toBe("toolUse");
|
||||
expect(done.message?.stopReason).toBe("toolUse");
|
||||
expect(events.map((event) => (event as { type?: string }).type)).toEqual(["done"]);
|
||||
const done = events.at(-1) as {
|
||||
reason?: unknown;
|
||||
message?: { content?: unknown; stopReason?: unknown };
|
||||
};
|
||||
expect(done.reason).toBe("length");
|
||||
expect(done.message).toMatchObject({ content: rawToolText, stopReason: "length" });
|
||||
});
|
||||
|
||||
it("passes through bracketed text when no configured tool names match", async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user