mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 10:31:35 +00:00
fix(cron): bind scheduled authority to creator accounts
This commit is contained in:
@@ -460,7 +460,10 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
sourceTool: "subagent_announce",
|
||||
};
|
||||
params.trustedInternalHandoff = true;
|
||||
params.scheduledToolPolicy = { ownerSessionKey: "agent:main:discord:group:ops" };
|
||||
params.scheduledToolPolicy = {
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "default",
|
||||
};
|
||||
let receivedOptions: unknown;
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
receivedOptions = options;
|
||||
|
||||
@@ -727,7 +727,10 @@ describe("createCopilotToolBridge", () => {
|
||||
jobId: "job-1",
|
||||
memoryFlushWritePath: ".memory/append.md",
|
||||
toolsAllow: ["read", "edit"],
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
@@ -745,6 +748,7 @@ describe("createCopilotToolBridge", () => {
|
||||
expect(opts.runtimeToolAllowlist).toEqual(["read", "edit"]);
|
||||
expect(opts.scheduledToolPolicy).toEqual({
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@ import {
|
||||
WorkerTranscriptCommitResultSchema,
|
||||
WorkerTranscriptMessageSchema,
|
||||
WORKER_HEARTBEAT_INTERVAL_MS,
|
||||
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
|
||||
WORKER_LIVE_EVENT_PROTOCOL_FEATURE,
|
||||
WORKER_PROTOCOL_FEATURES,
|
||||
WORKER_PROTOCOL_MAX_FEATURE_LENGTH,
|
||||
@@ -1070,6 +1071,7 @@ export {
|
||||
WorkerTranscriptCommitResultSchema,
|
||||
WorkerTranscriptMessageSchema,
|
||||
WORKER_HEARTBEAT_INTERVAL_MS,
|
||||
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
|
||||
WORKER_LIVE_EVENT_PROTOCOL_FEATURE,
|
||||
WORKER_PROTOCOL_FEATURES,
|
||||
WORKER_PROTOCOL_MAX_FEATURE_LENGTH,
|
||||
|
||||
@@ -130,6 +130,7 @@ const CronDisplayNameSchema = Type.String({ minLength: 1, maxLength: 200, patter
|
||||
const CronOwnerSchema = closedObject({
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
sessionKey: Type.Optional(NonEmptyString),
|
||||
accountId: Type.Optional(NonEmptyString),
|
||||
});
|
||||
const CronAnnounceChannelSchema = Type.Union([Type.Literal("last"), NonBlankString]);
|
||||
const CronFailoverReasonSchema = Type.Union([
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WorkerProtocolCloseReasonSchema,
|
||||
WorkerTranscriptCommitRequestFrameSchema,
|
||||
WorkerTranscriptCommitResponseFrameSchema,
|
||||
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
|
||||
WORKER_PROTOCOL_FEATURES,
|
||||
WORKER_RPC_SET_VERSION,
|
||||
WORKER_TRANSCRIPT_MAX_JSON_DEPTH,
|
||||
@@ -296,6 +297,7 @@ describe("worker protocol schemas", () => {
|
||||
it("validates the additive live-event protocol", () => {
|
||||
expect(WORKER_RPC_SET_VERSION).toBe(1);
|
||||
expect(WORKER_PROTOCOL_FEATURES).toContain("worker-live-event-v1");
|
||||
expect(WORKER_PROTOCOL_FEATURES).toContain(WORKER_LAUNCH_V2_PROTOCOL_FEATURE);
|
||||
for (const validEvent of [
|
||||
assistant,
|
||||
event("thinking", { text: "x", delta: "x" }),
|
||||
|
||||
@@ -35,10 +35,12 @@ export const WORKER_PROTOCOL_METHODS = [
|
||||
] as const;
|
||||
export const WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE = "worker-transcript-commit-v1";
|
||||
export const WORKER_LIVE_EVENT_PROTOCOL_FEATURE = "worker-live-event-v1";
|
||||
export const WORKER_LAUNCH_V2_PROTOCOL_FEATURE = "worker-launch-v2";
|
||||
export const WORKER_PROTOCOL_FEATURES = [
|
||||
"worker-heartbeat-v1",
|
||||
WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE,
|
||||
WORKER_LIVE_EVENT_PROTOCOL_FEATURE,
|
||||
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
|
||||
"worker-inference-v1",
|
||||
] as const;
|
||||
export const WORKER_PROTOCOL_MAX_METHOD_LENGTH = 64;
|
||||
|
||||
@@ -30,6 +30,20 @@ vi.mock("../channels/plugins/session-conversation.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
getLoadedChannelPlugin: () => ({
|
||||
config: {
|
||||
listAccountIds: (config: OpenClawConfig) => [
|
||||
"default",
|
||||
...Object.keys(
|
||||
(config.channels?.whatsapp as { accounts?: Record<string, unknown> } | undefined)
|
||||
?.accounts ?? {},
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
async function writeSessionEntries(
|
||||
storePath: string,
|
||||
entries: Record<string, unknown>,
|
||||
@@ -206,6 +220,38 @@ describe("resolveGroupToolPolicy group context validation", () => {
|
||||
|
||||
expect(policy).toEqual({ allow: ["read"] });
|
||||
});
|
||||
|
||||
it("fails closed when scheduled authority names a removed account", () => {
|
||||
expect(
|
||||
resolveGroupToolPolicy({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:whatsapp:group:safe-room",
|
||||
accountId: "removed",
|
||||
requireConfiguredAccount: true,
|
||||
}),
|
||||
).toEqual({ allow: [] });
|
||||
});
|
||||
|
||||
it("resolves scheduled group policy for a still-configured named account", () => {
|
||||
const accountCfg = {
|
||||
...cfg,
|
||||
channels: {
|
||||
whatsapp: {
|
||||
...cfg.channels?.whatsapp,
|
||||
accounts: { work: {} },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
resolveGroupToolPolicy({
|
||||
config: accountCfg,
|
||||
sessionKey: "agent:main:whatsapp:group:safe-room",
|
||||
accountId: "work",
|
||||
requireConfiguredAccount: true,
|
||||
}),
|
||||
).toEqual({ allow: ["read"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubagentToolPolicyForSession", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { AgentToolsConfig } from "../config/types.tools.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
parseRawSessionConversationRef,
|
||||
@@ -466,6 +467,8 @@ export function resolveGroupToolPolicy(params: {
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
accountId?: string | null;
|
||||
/** Scheduled authority must not fall back from a removed named account. */
|
||||
requireConfiguredAccount?: boolean;
|
||||
senderPolicyMode?: "always" | "never";
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
@@ -489,13 +492,13 @@ export function resolveGroupToolPolicy(params: {
|
||||
...(spawnedContext.groupIds ?? []),
|
||||
...buildScopedGroupIdCandidates(trustedGroup.groupId),
|
||||
]);
|
||||
if (groupIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const channelRaw = sessionContext.channel ?? spawnedContext.channel ?? params.messageProvider;
|
||||
const channel = normalizeMessageChannel(channelRaw);
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
return params.requireConfiguredAccount && accountId !== DEFAULT_ACCOUNT_ID
|
||||
? { allow: [] }
|
||||
: undefined;
|
||||
}
|
||||
let plugin;
|
||||
try {
|
||||
@@ -503,13 +506,32 @@ export function resolveGroupToolPolicy(params: {
|
||||
} catch {
|
||||
plugin = undefined;
|
||||
}
|
||||
if (params.requireConfiguredAccount && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
let configured: boolean;
|
||||
try {
|
||||
configured =
|
||||
plugin?.config
|
||||
.listAccountIds(params.config)
|
||||
.some((candidate) => normalizeAccountId(candidate) === accountId) === true;
|
||||
} catch {
|
||||
configured = false;
|
||||
}
|
||||
if (!configured) {
|
||||
// A named creator account is an authority boundary, not a fallback hint.
|
||||
// If it disappears, deny the scheduled surface instead of selecting default config.
|
||||
return { allow: [] };
|
||||
}
|
||||
}
|
||||
if (groupIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (const groupId of groupIds) {
|
||||
const toolsConfig = plugin?.groups?.resolveToolPolicy?.({
|
||||
cfg: params.config,
|
||||
groupId,
|
||||
groupChannel: trustedGroup.dropped ? null : params.groupChannel,
|
||||
groupSpace: trustedGroup.dropped ? null : params.groupSpace,
|
||||
accountId: params.accountId,
|
||||
accountId,
|
||||
senderPolicyMode: params.senderPolicyMode,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
@@ -527,7 +549,7 @@ export function resolveGroupToolPolicy(params: {
|
||||
messageProvider: channel,
|
||||
groupId: groupIds[0],
|
||||
groupIdCandidates: groupIds.slice(1),
|
||||
accountId: params.accountId,
|
||||
accountId,
|
||||
senderPolicyMode: params.senderPolicyMode,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
|
||||
@@ -2434,7 +2434,10 @@ describe("prepareCliRunContext", () => {
|
||||
provider: "native-cli",
|
||||
runId: "run-test-loopback-prompt-tools",
|
||||
config: createCliBackendConfig({ bundleMcp: true }),
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:worker:discord:group:ops" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:worker:discord:group:ops",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
cliSessionBinding: {
|
||||
sessionId: "cli-session",
|
||||
promptToolNamesHash: "old-tool-surface",
|
||||
@@ -2461,7 +2464,10 @@ describe("prepareCliRunContext", () => {
|
||||
workspaceDir: expect.any(String),
|
||||
modelProvider: "native-cli",
|
||||
modelId: "test-model",
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:worker:discord:group:ops" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:worker:discord:group:ops",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
});
|
||||
expect(context.systemPrompt).toContain("## Memory Recall");
|
||||
expect(context.systemPrompt).toContain("tools=memory_search");
|
||||
@@ -2982,7 +2988,10 @@ describe("prepareCliRunContext", () => {
|
||||
sessionKey: "agent:main:main",
|
||||
provider: "claude-cli",
|
||||
toolsAllow: ["write"],
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
});
|
||||
cleanup = context.preparedBackend.cleanup;
|
||||
|
||||
@@ -2997,11 +3006,15 @@ describe("prepareCliRunContext", () => {
|
||||
]);
|
||||
expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.scheduledToolPolicy).toEqual({
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "default",
|
||||
});
|
||||
expect(resolveMcpLoopbackPolicyTools).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolsAllow: ["write"],
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const projected = resolveMcpLoopbackPolicyTools.mock.calls[0]?.[0];
|
||||
|
||||
@@ -196,7 +196,10 @@ describe("resolveConversationCapabilityProfile", () => {
|
||||
const legacy = resolveConversationCapabilityProfile(baseParams);
|
||||
const scheduled = resolveConversationCapabilityProfile({
|
||||
...baseParams,
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:main:whatsapp:group:team",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
});
|
||||
|
||||
expect(legacy.policy.senderPolicy).toEqual({ deny: ["write"] });
|
||||
|
||||
@@ -222,7 +222,7 @@ export function resolveConversationCapabilityProfile(
|
||||
groupId: trustedGroup.groupId,
|
||||
groupChannel: trustedGroupChannel,
|
||||
groupSpace: trustedGroupSpace,
|
||||
accountId: params.agentAccountId,
|
||||
accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
@@ -231,6 +231,7 @@ export function resolveConversationCapabilityProfile(
|
||||
trustedInternalHandoff: params.trustedInternalHandoff,
|
||||
senderPolicyMode: params.scheduledToolPolicy || isOwnerInternalSession ? "never" : "always",
|
||||
groupPolicySessionKey: params.scheduledToolPolicy?.ownerSessionKey,
|
||||
requireConfiguredGroupAccount: Boolean(params.scheduledToolPolicy),
|
||||
});
|
||||
const { groupPolicy, senderPolicy, subagentPolicy, inheritedToolPolicy } = requesterPolicies;
|
||||
const profilePolicy = resolveToolProfilePolicy(effective.profile);
|
||||
|
||||
@@ -693,7 +693,8 @@ function resolvePluginHarnessToolPolicies(
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
accountId: params.agentAccountId,
|
||||
accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId,
|
||||
requireConfiguredAccount: Boolean(params.scheduledToolPolicy),
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
|
||||
@@ -75,4 +75,29 @@ describe("createOpenClawTools Gateway caller identity", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses scheduled creator account authority without changing live delivery routing", async () => {
|
||||
mocks.observedIdentities.length = 0;
|
||||
|
||||
const tool = requireTool("synthetic_direct_cron_plugin", {
|
||||
agentChannel: "discord",
|
||||
agentTo: "channel:123",
|
||||
agentAccountId: "delivery-account",
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:main:discord:channel:creator",
|
||||
ownerAccountId: "creator-account",
|
||||
},
|
||||
});
|
||||
await tool.execute("tool-call-scheduled", {});
|
||||
|
||||
expect(mocks.observedIdentities).toEqual([
|
||||
{
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:discord:channel:123",
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceTo: "channel:123",
|
||||
turnSourceAccountId: "creator-account",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,6 +262,10 @@ export function createOpenClawTools(
|
||||
accountId: options?.agentAccountId,
|
||||
threadId: options?.agentThreadId,
|
||||
});
|
||||
// Scheduled turns keep delivery routing live, but Gateway authorization remains bound to the
|
||||
// authenticated creator account captured in the immutable scheduled authority envelope.
|
||||
const gatewayCallerAccountId =
|
||||
options?.scheduledToolPolicy?.ownerAccountId ?? options?.agentAccountId;
|
||||
const runtimeWebTools = getActiveRuntimeWebToolsMetadata();
|
||||
const sandbox =
|
||||
options?.sandboxRoot && options?.sandboxFsBridge
|
||||
@@ -495,6 +499,7 @@ export function createOpenClawTools(
|
||||
]),
|
||||
createCronTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
agentAccountId: gatewayCallerAccountId,
|
||||
currentDeliveryContext: {
|
||||
channel: options?.agentChannel,
|
||||
to: options?.currentChannelId ?? options?.agentTo,
|
||||
@@ -743,7 +748,10 @@ export function createOpenClawTools(
|
||||
options?.recordToolPrepStage?.("openclaw-tools:client-capabilities");
|
||||
|
||||
const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId;
|
||||
const wrapGatewayCallerIdentity = createGatewayToolCallerWrapper(hookAgentId, options);
|
||||
const wrapGatewayCallerIdentity = createGatewayToolCallerWrapper(
|
||||
hookAgentId,
|
||||
options ? { ...options, agentAccountId: gatewayCallerAccountId } : options,
|
||||
);
|
||||
|
||||
if (options?.wrapBeforeToolCallHook === false) {
|
||||
return allTools.map(wrapGatewayCallerIdentity);
|
||||
|
||||
@@ -62,6 +62,8 @@ type RequesterToolPolicyParams = {
|
||||
senderPolicyMode?: SenderPolicyMode;
|
||||
/** Group session selected by a trusted scheduled authority envelope. */
|
||||
groupPolicySessionKey?: string;
|
||||
/** Fail closed when scheduled authority names a removed non-default account. */
|
||||
requireConfiguredGroupAccount?: boolean;
|
||||
};
|
||||
|
||||
function policyFromEnvelope(
|
||||
@@ -194,6 +196,7 @@ export function resolveRequesterToolPolicies(
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
accountId: params.accountId,
|
||||
requireConfiguredAccount: params.requireConfiguredGroupAccount,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveScheduledToolPolicyContext } from "./scheduled-tool-policy.js";
|
||||
|
||||
describe("resolveScheduledToolPolicyContext", () => {
|
||||
it("requires both a persisted cap and a trusted owner session", () => {
|
||||
it("requires a persisted cap and a trusted owner session/account pair", () => {
|
||||
expect(
|
||||
resolveScheduledToolPolicyContext({
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
@@ -17,6 +17,13 @@ describe("resolveScheduledToolPolicyContext", () => {
|
||||
resolveScheduledToolPolicyContext({
|
||||
toolsAllow: ["write"],
|
||||
ownerSessionKey: " ",
|
||||
ownerAccountId: "work",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveScheduledToolPolicyContext({
|
||||
toolsAllow: ["write"],
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
@@ -26,7 +33,11 @@ describe("resolveScheduledToolPolicyContext", () => {
|
||||
resolveScheduledToolPolicyContext({
|
||||
toolsAllow: [],
|
||||
ownerSessionKey: " agent:main:discord:group:ops ",
|
||||
ownerAccountId: " work ",
|
||||
}),
|
||||
).toEqual({ ownerSessionKey: "agent:main:discord:group:ops" });
|
||||
).toEqual({
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "work",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
*/
|
||||
export type ScheduledToolPolicyContext = {
|
||||
ownerSessionKey: string;
|
||||
ownerAccountId: string;
|
||||
};
|
||||
|
||||
/** Builds scheduled policy context only when both the cap and trusted owner exist. */
|
||||
export function resolveScheduledToolPolicyContext(params: {
|
||||
toolsAllow?: readonly string[];
|
||||
ownerSessionKey?: string | null;
|
||||
ownerAccountId?: string | null;
|
||||
}): ScheduledToolPolicyContext | undefined {
|
||||
if (params.toolsAllow === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const ownerSessionKey = params.ownerSessionKey?.trim();
|
||||
return ownerSessionKey ? { ownerSessionKey } : undefined;
|
||||
const ownerAccountId = params.ownerAccountId?.trim();
|
||||
// Accountless jobs predate creator-account authority. Keep their shipped sender-policy path;
|
||||
// inferring authority from a later delivery account would cross the account boundary.
|
||||
return ownerSessionKey && ownerAccountId ? { ownerSessionKey, ownerAccountId } : undefined;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("../../config/sessions/delivery-info.js", () => ({
|
||||
import { GatewayClientRequestError } from "../../gateway/client.js";
|
||||
import { buildAgentPeerSessionKey } from "../../routing/session-key.js";
|
||||
import { createCronTool } from "./cron-tool.js";
|
||||
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
|
||||
|
||||
describe("cron tool", () => {
|
||||
type SchemaLike = {
|
||||
@@ -1551,6 +1552,31 @@ describe("cron tool", () => {
|
||||
expect(sessionKey).toBe(callerSessionKey);
|
||||
});
|
||||
|
||||
it("forwards authenticated source account separately from delivery account", async () => {
|
||||
let identity: ReturnType<typeof getGatewayToolCallerIdentity> = undefined;
|
||||
const tool = createCronTool(
|
||||
{
|
||||
agentSessionKey: "agent:main:discord:channel:ops",
|
||||
agentAccountId: "source-account",
|
||||
currentDeliveryContext: { accountId: "delivery-account" },
|
||||
},
|
||||
{
|
||||
callGatewayTool: async <T>() => {
|
||||
identity = getGatewayToolCallerIdentity();
|
||||
return { enabled: true, jobs: 0 } as T;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await tool.execute("call-source-account", { action: "status" });
|
||||
|
||||
expect(identity).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:discord:channel:ops",
|
||||
turnSourceAccountId: "source-account",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit job.sessionKey on add", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
|
||||
@@ -792,7 +792,11 @@ Restricted isolated runs may only self status/list, current get/runs/remove, and
|
||||
const callerScope = resolveCronToolCallerScope(opts, runtimeConfig);
|
||||
const callerIdentity =
|
||||
callerScope && opts?.agentSessionKey?.trim()
|
||||
? { agentId: callerScope.agentId, sessionKey: opts.agentSessionKey.trim() }
|
||||
? {
|
||||
agentId: callerScope.agentId,
|
||||
sessionKey: opts.agentSessionKey.trim(),
|
||||
turnSourceAccountId: opts.agentAccountId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return await withGatewayToolCallerIdentity(callerIdentity, async () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ export type CronCreatorToolAllowlistEntry =
|
||||
|
||||
export type CronToolOptions = {
|
||||
agentSessionKey?: string;
|
||||
/** Authenticated source account; authority must not be inferred from delivery. */
|
||||
agentAccountId?: string;
|
||||
currentDeliveryContext?: DeliveryContext;
|
||||
/**
|
||||
* Effective tool surface visible to the caller that created or edited a cron job.
|
||||
|
||||
@@ -69,7 +69,8 @@ export function resolveWebSearchToolPolicy(
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
accountId: params.agentAccountId,
|
||||
accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId,
|
||||
requireConfiguredAccount: Boolean(params.scheduledToolPolicy),
|
||||
senderPolicyMode: params.scheduledToolPolicy ? ("never" as const) : ("always" as const),
|
||||
};
|
||||
const senderPolicyParams = {
|
||||
@@ -88,6 +89,7 @@ export function resolveWebSearchToolPolicy(
|
||||
trustedInternalHandoff: params.trustedInternalHandoff,
|
||||
senderPolicyMode: params.scheduledToolPolicy ? "never" : "always",
|
||||
groupPolicySessionKey: params.scheduledToolPolicy?.ownerSessionKey,
|
||||
requireConfiguredGroupAccount: Boolean(params.scheduledToolPolicy),
|
||||
});
|
||||
const persistentGroupPolicy = requesterPolicies.delegated
|
||||
? undefined
|
||||
|
||||
@@ -388,6 +388,8 @@ export type SessionEntry = SessionRestartRecoveryState &
|
||||
toolsAllowIsDefault?: boolean;
|
||||
/** Server-stamped creator session used to resolve sender-independent group policy. */
|
||||
ownerSessionKey?: string;
|
||||
/** Server-stamped creator account paired with ownerSessionKey. */
|
||||
ownerAccountId?: string;
|
||||
cliSessionBindingFacts?: {
|
||||
extraSystemPromptStatic?: string;
|
||||
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
|
||||
|
||||
@@ -277,6 +277,7 @@ function createCronPromptExecutor(params: {
|
||||
const scheduledToolPolicy = resolveScheduledToolPolicyContext({
|
||||
toolsAllow: params.agentPayload?.toolsAllow,
|
||||
ownerSessionKey: params.job.owner?.sessionKey,
|
||||
ownerAccountId: params.job.owner?.accountId,
|
||||
});
|
||||
if (!params.sourceDelivery) {
|
||||
logWarn(
|
||||
|
||||
@@ -146,6 +146,7 @@ describe("createPersistCronSessionEntry", () => {
|
||||
toolsAllow: ["image_generate", "write"],
|
||||
toolsAllowIsDefault: true,
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "work",
|
||||
persistSessionEntry,
|
||||
});
|
||||
|
||||
@@ -172,6 +173,7 @@ describe("createPersistCronSessionEntry", () => {
|
||||
toolsAllow: ["image_generate", "write"],
|
||||
toolsAllowIsDefault: true,
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "work",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -204,6 +204,7 @@ export function createCronRunContinuationSession(params: {
|
||||
toolsAllow?: string[];
|
||||
toolsAllowIsDefault?: boolean;
|
||||
ownerSessionKey?: string;
|
||||
ownerAccountId?: string;
|
||||
cliSessionBindingFacts?: {
|
||||
extraSystemPromptStatic?: string;
|
||||
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
|
||||
@@ -216,8 +217,13 @@ export function createCronRunContinuationSession(params: {
|
||||
phase: "running" as const,
|
||||
...(params.toolsAllow !== undefined ? { toolsAllow: [...params.toolsAllow] } : {}),
|
||||
...(params.toolsAllowIsDefault === true ? { toolsAllowIsDefault: true } : {}),
|
||||
...(params.toolsAllow !== undefined && params.ownerSessionKey?.trim()
|
||||
? { ownerSessionKey: params.ownerSessionKey.trim() }
|
||||
...(params.toolsAllow !== undefined &&
|
||||
params.ownerSessionKey?.trim() &&
|
||||
params.ownerAccountId?.trim()
|
||||
? {
|
||||
ownerSessionKey: params.ownerSessionKey.trim(),
|
||||
ownerAccountId: params.ownerAccountId.trim(),
|
||||
}
|
||||
: {}),
|
||||
...(params.cliSessionBindingFacts
|
||||
? { cliSessionBindingFacts: { ...params.cliSessionBindingFacts } }
|
||||
|
||||
@@ -34,7 +34,11 @@ function makeParams() {
|
||||
sessionTarget: "isolated",
|
||||
payload: { kind: "agentTurn", message: "check allowed tools" },
|
||||
delivery: { mode: "none" },
|
||||
owner: { agentId: "main", sessionKey: "agent:main:whatsapp:group:team" },
|
||||
owner: {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:whatsapp:group:team",
|
||||
accountId: "default",
|
||||
},
|
||||
} as never,
|
||||
message: "check allowed tools",
|
||||
sessionKey: "cron:tools-allow",
|
||||
@@ -77,13 +81,13 @@ function makeParamsWithDefaultToolsAllow(toolsAllow: string[]) {
|
||||
function requireEmbeddedAgentCall(): {
|
||||
jobId?: string;
|
||||
toolsAllow?: string[];
|
||||
scheduledToolPolicy?: { ownerSessionKey: string };
|
||||
scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string };
|
||||
} {
|
||||
const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as
|
||||
| {
|
||||
jobId?: string;
|
||||
toolsAllow?: string[];
|
||||
scheduledToolPolicy?: { ownerSessionKey: string };
|
||||
scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string };
|
||||
}
|
||||
| undefined;
|
||||
if (!call) {
|
||||
@@ -134,6 +138,21 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"keeps capped accountless legacy jobs on the ordinary sender-policy path",
|
||||
{ timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS },
|
||||
async () => {
|
||||
const params = makeParamsWithToolsAllow(["cron"]);
|
||||
delete (params.job as { owner?: { accountId?: string } }).owner?.accountId;
|
||||
|
||||
await runCronIsolatedAgentTurn(params);
|
||||
|
||||
const call = requireEmbeddedAgentCall();
|
||||
expect(call.toolsAllow).toEqual(["cron"]);
|
||||
expect(call.scheduledToolPolicy).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"passes through isolated cron toolsAllow=cron self-removal path",
|
||||
{ timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS },
|
||||
@@ -146,6 +165,7 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => {
|
||||
expect(call.toolsAllow).toEqual(["cron"]);
|
||||
expect(call.scheduledToolPolicy).toEqual({
|
||||
ownerSessionKey: "agent:main:whatsapp:group:team",
|
||||
ownerAccountId: "default",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1127,6 +1127,7 @@ async function prepareCronRunContext(params: {
|
||||
toolsAllow: agentPayload?.toolsAllow,
|
||||
toolsAllowIsDefault: agentPayload?.toolsAllowIsDefault,
|
||||
ownerSessionKey: input.job.owner?.sessionKey,
|
||||
ownerAccountId: input.job.owner?.accountId,
|
||||
cliSessionBindingFacts: {
|
||||
sourceReplyDeliveryMode: sourceDelivery.sourceReplyDeliveryMode,
|
||||
requireExplicitMessageTarget: sourceDelivery.messageTool.requireExplicitTarget,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { normalizeOptionalAccountId } from "../routing/account-id.js";
|
||||
import { sanitizeAgentId } from "../routing/session-key.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { shouldDefaultCronDeliveryToAnnounce } from "./delivery-defaults.js";
|
||||
@@ -370,10 +371,14 @@ export function normalizeCronJobInput(
|
||||
if (isRecord(base.owner)) {
|
||||
const agentId = normalizeOptionalString(base.owner.agentId);
|
||||
const sessionKey = normalizeOptionalString(base.owner.sessionKey);
|
||||
if (agentId || sessionKey) {
|
||||
const accountId = normalizeOptionalAccountId(
|
||||
typeof base.owner.accountId === "string" ? base.owner.accountId : undefined,
|
||||
);
|
||||
if (agentId || sessionKey || accountId) {
|
||||
next.owner = {
|
||||
...(agentId ? { agentId: sanitizeAgentId(agentId) } : {}),
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(accountId ? { accountId } : {}),
|
||||
};
|
||||
} else {
|
||||
delete next.owner;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveCronTriggerMinIntervalMs } from "../../config/cron-limits.js";
|
||||
import type { CronConfig } from "../../config/types.cron.js";
|
||||
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { compileSafeRegexDetailed } from "../../security/safe-regex.js";
|
||||
import { isCronJobActive } from "../active-jobs.js";
|
||||
@@ -1097,15 +1098,17 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
|
||||
}
|
||||
const ownerAgentId = normalizeOptionalAgentId(input.owner?.agentId);
|
||||
const ownerSessionKey = normalizeOptionalString(input.owner?.sessionKey);
|
||||
const ownerAccountId = normalizeOptionalAccountId(input.owner?.accountId);
|
||||
const job: CronJob = {
|
||||
id,
|
||||
...(declarationKey ? { declarationKey } : {}),
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(ownerAgentId || ownerSessionKey
|
||||
...(ownerAgentId || ownerSessionKey || ownerAccountId
|
||||
? {
|
||||
owner: {
|
||||
...(ownerAgentId ? { agentId: ownerAgentId } : {}),
|
||||
...(ownerSessionKey ? { sessionKey: ownerSessionKey } : {}),
|
||||
...(ownerAccountId ? { accountId: ownerAccountId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -11,6 +11,24 @@ function roundTrip(schedule: CronSchedule): CronSchedule | null {
|
||||
}
|
||||
|
||||
describe("schedule column codec round-trip", () => {
|
||||
it("round-trips the creator account through the additive job_json envelope", () => {
|
||||
const job = projectCronJobThroughStorageCodec(
|
||||
makeCronJob({
|
||||
owner: {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:discord:group:ops",
|
||||
accountId: "work",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(job.owner).toEqual({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:discord:group:ops",
|
||||
accountId: "work",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips pacing through the additive job_json envelope", () => {
|
||||
const job = projectCronJobThroughStorageCodec(
|
||||
makeCronJob({ pacing: { min: "15m", max: "4h" } }),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
|
||||
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
|
||||
import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js";
|
||||
import { normalizeCronJobInput } from "../normalize.js";
|
||||
import { getInvalidPersistedCronJobReason } from "../persisted-shape.js";
|
||||
@@ -271,6 +272,11 @@ function pacingFromRow(row: CronJobRow): CronPacing | undefined {
|
||||
}
|
||||
|
||||
function rowToCronJob(row: CronJobRow): CronJob | null {
|
||||
const jobJson = parseJsonObject<Record<string, unknown>>(row.job_json, {});
|
||||
const jsonOwner = isRecord(jobJson.owner) ? jobJson.owner : undefined;
|
||||
const ownerAccountId = normalizeOptionalAccountId(
|
||||
typeof jsonOwner?.accountId === "string" ? jsonOwner.accountId : undefined,
|
||||
);
|
||||
const schedule = scheduleFromRow(row);
|
||||
const payload = payloadFromRow(row);
|
||||
const delivery = deliveryFromRow(row);
|
||||
@@ -285,11 +291,12 @@ function rowToCronJob(row: CronJobRow): CronJob | null {
|
||||
id: row.job_id,
|
||||
...(row.declaration_key ? { declarationKey: row.declaration_key } : {}),
|
||||
...(row.display_name ? { displayName: row.display_name } : {}),
|
||||
...(row.owner_agent_id || row.owner_session_key
|
||||
...(row.owner_agent_id || row.owner_session_key || ownerAccountId
|
||||
? {
|
||||
owner: {
|
||||
...(row.owner_agent_id ? { agentId: row.owner_agent_id } : {}),
|
||||
...(row.owner_session_key ? { sessionKey: row.owner_session_key } : {}),
|
||||
...(ownerAccountId ? { accountId: ownerAccountId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -311,13 +311,17 @@ describe("cron trigger script evaluator", () => {
|
||||
const runHeadless = vi.fn(async () => completed({ value: { fire: false } }));
|
||||
const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless });
|
||||
|
||||
for (const ownerSessionKey of ["agent:main:discord:group:a", "agent:main:discord:group:b"]) {
|
||||
for (const [ownerSessionKey, ownerAccountId] of [
|
||||
["agent:main:discord:group:a", "alpha"],
|
||||
["agent:main:discord:group:b", "beta"],
|
||||
] as const) {
|
||||
await evaluate({
|
||||
jobId: "job-owner-session",
|
||||
script: "return result",
|
||||
state: null,
|
||||
toolsAllow: ["write"],
|
||||
ownerSessionKey,
|
||||
ownerAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,6 +329,10 @@ describe("cron trigger script evaluator", () => {
|
||||
"agent:main:discord:group:a",
|
||||
"agent:main:discord:group:b",
|
||||
]);
|
||||
expect(prepareRuntime.mock.calls.map(([params]) => params.ownerAccountId)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -83,6 +83,7 @@ type PrepareTriggerRuntime = (params: {
|
||||
agentId?: string;
|
||||
toolsAllow?: string[];
|
||||
ownerSessionKey?: string;
|
||||
ownerAccountId?: string;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<PreparedTriggerRuntime>;
|
||||
|
||||
@@ -109,6 +110,7 @@ async function prepareTriggerRuntime(params: {
|
||||
agentId?: string;
|
||||
toolsAllow?: string[];
|
||||
ownerSessionKey?: string;
|
||||
ownerAccountId?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<PreparedTriggerRuntime> {
|
||||
params.signal?.throwIfAborted();
|
||||
@@ -179,6 +181,7 @@ async function prepareTriggerRuntime(params: {
|
||||
scheduledToolPolicy: resolveScheduledToolPolicyContext({
|
||||
toolsAllow: params.toolsAllow,
|
||||
ownerSessionKey: params.ownerSessionKey,
|
||||
ownerAccountId: params.ownerAccountId,
|
||||
}),
|
||||
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
|
||||
})
|
||||
@@ -349,6 +352,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
|
||||
agentId: string;
|
||||
toolsAllow?: string[];
|
||||
ownerSessionKey?: string;
|
||||
ownerAccountId?: string;
|
||||
toolsAllowKey: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<PreparedTriggerRuntime> => {
|
||||
@@ -384,6 +388,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
|
||||
agentId: request.requestedAgentId,
|
||||
toolsAllow: request.toolsAllow,
|
||||
ownerSessionKey: request.ownerSessionKey,
|
||||
ownerAccountId: request.ownerAccountId,
|
||||
signal: request.signal,
|
||||
});
|
||||
const entry: TriggerRuntimeCacheEntry = {
|
||||
@@ -410,6 +415,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
|
||||
script: string;
|
||||
toolsAllow?: string[];
|
||||
ownerSessionKey?: string;
|
||||
ownerAccountId?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
wallClockMs: number;
|
||||
maxToolCalls: number;
|
||||
@@ -430,6 +436,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
|
||||
const toolsAllowKey = JSON.stringify([
|
||||
params.toolsAllow ?? null,
|
||||
params.ownerSessionKey?.trim() || null,
|
||||
params.ownerAccountId?.trim() || null,
|
||||
]);
|
||||
const runtime = await resolveCachedRuntime({
|
||||
runtimeConfig,
|
||||
@@ -438,6 +445,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
|
||||
agentId,
|
||||
toolsAllow: params.toolsAllow,
|
||||
ownerSessionKey: params.ownerSessionKey,
|
||||
ownerAccountId: params.ownerAccountId,
|
||||
toolsAllowKey,
|
||||
signal: evaluationScope.signal,
|
||||
});
|
||||
@@ -605,6 +613,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) {
|
||||
streamBatch?: string;
|
||||
toolsAllow?: string[];
|
||||
ownerSessionKey?: string;
|
||||
ownerAccountId?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<CronTriggerEvaluationResult> => {
|
||||
if (activeTriggerEvaluations >= MAX_CONCURRENT_TRIGGER_EVALS) {
|
||||
@@ -632,6 +641,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) {
|
||||
streamBatch?: string;
|
||||
toolsAllow?: string[];
|
||||
ownerSessionKey?: string;
|
||||
ownerAccountId?: string;
|
||||
timeoutSeconds?: number;
|
||||
toolBudget?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
|
||||
@@ -463,6 +463,8 @@ export type CronJob = CronJobBase<
|
||||
owner?: {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
/** Authenticated account that created this scheduled authority envelope. */
|
||||
accountId?: string;
|
||||
};
|
||||
trigger?: CronTrigger;
|
||||
state: CronJobState;
|
||||
|
||||
@@ -66,6 +66,23 @@ describe("agent runtime identity token", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips the authenticated turn-source account", async () => {
|
||||
useTempHome();
|
||||
const runtimeToken = await importRuntimeTokenModule();
|
||||
const token = await runtimeToken.mintAgentRuntimeIdentityToken({
|
||||
agentId: "main",
|
||||
sessionKey: "session-1",
|
||||
turnSourceAccountId: " Work ",
|
||||
});
|
||||
|
||||
await expect(runtimeToken.verifyAgentRuntimeIdentityToken(token)).resolves.toEqual({
|
||||
kind: "agentRuntime",
|
||||
agentId: "main",
|
||||
sessionKey: "session-1",
|
||||
turnSourceAccountId: "work",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mint local credentials while rejecting invalid presented tokens", async () => {
|
||||
const home = useTempHome();
|
||||
const runtimeToken = await importRuntimeTokenModule();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { normalizeChatType } from "../channels/chat-type.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.public.js";
|
||||
import type { InternalChannelThreadingToolContext } from "../channels/threading-tool-context-internal.js";
|
||||
import { ensureExecApprovalsSnapshot, loadExecApprovalsAsync } from "../infra/exec-approvals.js";
|
||||
import { normalizeOptionalAccountId } from "../routing/account-id.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { safeEqualSecret } from "../security/secret-equal.js";
|
||||
import type { AgentRuntimeMessageActionContext } from "./message-action-turn-capability.js";
|
||||
@@ -18,6 +19,7 @@ export type AgentRuntimeIdentity = {
|
||||
kind: "agentRuntime";
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
turnSourceAccountId?: string;
|
||||
messageActionContext?: AgentRuntimeMessageActionContext;
|
||||
};
|
||||
|
||||
@@ -25,6 +27,7 @@ type AgentRuntimeIdentityTokenPayload = {
|
||||
kind: typeof AGENT_RUNTIME_IDENTITY_TOKEN_KIND;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
turnSourceAccountId?: string;
|
||||
messageActionContext?: AgentRuntimeMessageActionContext;
|
||||
};
|
||||
|
||||
@@ -160,6 +163,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
|
||||
kind?: unknown;
|
||||
agentId?: unknown;
|
||||
sessionKey?: unknown;
|
||||
turnSourceAccountId?: unknown;
|
||||
messageActionContext?: unknown;
|
||||
};
|
||||
if (
|
||||
@@ -171,6 +175,9 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
|
||||
}
|
||||
const agentId = normalizeAgentId(raw.agentId);
|
||||
const sessionKey = raw.sessionKey.trim();
|
||||
const turnSourceAccountId = normalizeOptionalAccountId(
|
||||
typeof raw.turnSourceAccountId === "string" ? raw.turnSourceAccountId : undefined,
|
||||
);
|
||||
if (!agentId || !sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -185,6 +192,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
|
||||
kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND,
|
||||
agentId,
|
||||
sessionKey,
|
||||
...(turnSourceAccountId ? { turnSourceAccountId } : {}),
|
||||
...(messageActionContext ? { messageActionContext } : {}),
|
||||
};
|
||||
} catch {
|
||||
@@ -196,6 +204,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
|
||||
export async function mintAgentRuntimeIdentityToken(params: {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
turnSourceAccountId?: string;
|
||||
messageActionContext?: AgentRuntimeMessageActionContext;
|
||||
}): Promise<string> {
|
||||
if (
|
||||
@@ -215,10 +224,12 @@ export async function mintAgentRuntimeIdentityToken(params: {
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
const turnSourceAccountId = normalizeOptionalAccountId(params.turnSourceAccountId);
|
||||
const payload = encodePayload({
|
||||
kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND,
|
||||
agentId: normalizeAgentId(params.agentId),
|
||||
sessionKey: params.sessionKey.trim(),
|
||||
...(turnSourceAccountId ? { turnSourceAccountId } : {}),
|
||||
...(messageActionContext ? { messageActionContext } : {}),
|
||||
});
|
||||
const signature = signPayload(await requireSharedAgentRuntimeIdentitySecret(), payload);
|
||||
@@ -250,6 +261,7 @@ export async function verifyAgentRuntimeIdentityToken(
|
||||
kind: "agentRuntime",
|
||||
agentId: payload.agentId,
|
||||
sessionKey: payload.sessionKey,
|
||||
...(payload.turnSourceAccountId ? { turnSourceAccountId: payload.turnSourceAccountId } : {}),
|
||||
...(payload.messageActionContext ? { messageActionContext: payload.messageActionContext } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -607,6 +607,7 @@ export function buildGatewayCronService(params: {
|
||||
streamBatch,
|
||||
toolsAllow: job.payload.toolsAllow,
|
||||
ownerSessionKey: job.owner?.sessionKey,
|
||||
ownerAccountId: job.owner?.accountId,
|
||||
abortSignal,
|
||||
}),
|
||||
}
|
||||
@@ -838,6 +839,7 @@ export function buildGatewayCronService(params: {
|
||||
streamBatch,
|
||||
toolsAllow: job.payload.toolsAllow,
|
||||
ownerSessionKey: job.owner?.sessionKey,
|
||||
ownerAccountId: job.owner?.accountId,
|
||||
timeoutSeconds: job.payload.timeoutSeconds,
|
||||
toolBudget: job.payload.toolBudget,
|
||||
abortSignal,
|
||||
|
||||
@@ -39,6 +39,7 @@ export type RestoredCronContinuation = {
|
||||
toolsAllow?: string[];
|
||||
toolsAllowIsDefault?: boolean;
|
||||
ownerSessionKey?: string;
|
||||
ownerAccountId?: string;
|
||||
cliSessionBindingFacts?: {
|
||||
extraSystemPromptStatic?: string;
|
||||
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
|
||||
|
||||
@@ -355,6 +355,7 @@ export function startAgentRunExecution(params: {
|
||||
? resolveScheduledToolPolicyContext({
|
||||
toolsAllow: params.restoredCronContinuation.toolsAllow,
|
||||
ownerSessionKey: params.restoredCronContinuation.ownerSessionKey,
|
||||
ownerAccountId: params.restoredCronContinuation.ownerAccountId,
|
||||
})
|
||||
: undefined,
|
||||
requireExplicitMessageTarget:
|
||||
|
||||
@@ -230,6 +230,9 @@ export async function persistAgentSessionPhase(params: {
|
||||
...(marker.ownerSessionKey?.trim()
|
||||
? { ownerSessionKey: marker.ownerSessionKey.trim() }
|
||||
: {}),
|
||||
...(marker.ownerAccountId?.trim()
|
||||
? { ownerAccountId: marker.ownerAccountId.trim() }
|
||||
: {}),
|
||||
...(marker.cliSessionBindingFacts
|
||||
? { cliSessionBindingFacts: { ...marker.cliSessionBindingFacts } }
|
||||
: {}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js";
|
||||
import { normalizeAccountId } from "../../routing/account-id.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
|
||||
import type { GatewayClient } from "./types.js";
|
||||
@@ -7,6 +8,7 @@ export type CronCallerScope = {
|
||||
kind: "agentTool";
|
||||
agentId: string;
|
||||
sessionKey?: string;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
export function readCronCallerScope(
|
||||
@@ -20,6 +22,7 @@ export function readCronCallerScope(
|
||||
kind: "agentTool",
|
||||
agentId: normalizeAgentId(identity.agentId),
|
||||
sessionKey: identity.sessionKey?.trim() || undefined,
|
||||
accountId: normalizeAccountId(identity.turnSourceAccountId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,11 +78,20 @@ export function cronJobMatchesCallerScope(params: {
|
||||
if (isOperatorCommandCronJob(params.job)) {
|
||||
return false;
|
||||
}
|
||||
const ownerAccountId = params.job.owner?.accountId;
|
||||
// Operator-created records may name an account without an owner agent; account ownership is
|
||||
// therefore an independent boundary, not a refinement of ownerAgentId.
|
||||
if (ownerAccountId && normalizeAccountId(ownerAccountId) !== params.callerScope.accountId) {
|
||||
return false;
|
||||
}
|
||||
// Declarative jobs retain their stamped owner when an operator retargets execution.
|
||||
// Ownerless jobs predate attribution, so keep their routing-based visibility.
|
||||
const ownerAgentId = resolveCronJobOwnerAgentId(params.job);
|
||||
if (ownerAgentId) {
|
||||
return ownerAgentId === params.callerScope.agentId;
|
||||
if (ownerAgentId !== params.callerScope.agentId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
resolveCronJobEffectiveAgentId(params.job, params.defaultAgentId) !== params.callerScope.agentId
|
||||
@@ -154,6 +166,7 @@ export function applyCronCreateCallerScopeDefault(
|
||||
owner: {
|
||||
agentId: callerScope.agentId,
|
||||
...(callerScope.sessionKey ? { sessionKey: callerScope.sessionKey } : {}),
|
||||
accountId: callerScope.accountId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ function createCronJob(overrides: Partial<CronJob> = {}): CronJob {
|
||||
};
|
||||
}
|
||||
|
||||
function callerClient(agentId: string): GatewayClient {
|
||||
function callerClient(agentId: string, accountId?: string): GatewayClient {
|
||||
return {
|
||||
connect: {} as GatewayClient["connect"],
|
||||
internal: {
|
||||
@@ -271,6 +271,7 @@ function callerClient(agentId: string): GatewayClient {
|
||||
kind: "agentRuntime",
|
||||
agentId,
|
||||
sessionKey: `agent:${agentId}:main`,
|
||||
...(accountId ? { turnSourceAccountId: accountId } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1100,7 +1101,11 @@ describe("cron method validation", () => {
|
||||
);
|
||||
|
||||
const payload = requireCronAddPayload(context);
|
||||
expect(payload.owner).toEqual({ agentId: "ops", sessionKey: "agent:ops:main" });
|
||||
expect(payload.owner).toEqual({
|
||||
agentId: "ops",
|
||||
sessionKey: "agent:ops:main",
|
||||
accountId: "default",
|
||||
});
|
||||
const options = requireRecord(context.cron.add.mock.calls[0]?.[1], "cron.add options");
|
||||
const matchesExisting = options.matchesExisting as ((job: CronJob) => boolean) | undefined;
|
||||
expect(matchesExisting?.(createCronJob({ agentId: "ops" }))).toBe(true);
|
||||
@@ -1108,12 +1113,33 @@ describe("cron method validation", () => {
|
||||
expect(matchesExisting?.(createCronJob({ agentId: "worker", owner: { agentId: "ops" } }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
matchesExisting?.(
|
||||
createCronJob({
|
||||
agentId: "worker",
|
||||
owner: { agentId: "ops", accountId: "work" },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(matchesExisting?.(createCronJob({ agentId: "ops", owner: { agentId: "worker" } }))).toBe(
|
||||
false,
|
||||
);
|
||||
expectCronSuccess(respond);
|
||||
});
|
||||
|
||||
it("stamps declaration ownership from the authenticated source account", async () => {
|
||||
const { context, respond } = await invokeCronAdd(agentTurnCronParams(), {
|
||||
client: callerClient("ops", "work"),
|
||||
});
|
||||
|
||||
expect(requireCronAddPayload(context).owner).toEqual({
|
||||
agentId: "ops",
|
||||
sessionKey: "agent:ops:main",
|
||||
accountId: "work",
|
||||
});
|
||||
expectCronSuccess(respond);
|
||||
});
|
||||
|
||||
it("keeps scoped read access with the stamped owner after operator retargeting", async () => {
|
||||
const job = createCronJob({
|
||||
agentId: "worker",
|
||||
@@ -1134,6 +1160,73 @@ describe("cron method validation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps new jobs scoped to their creator account while preserving accountless legacy jobs", async () => {
|
||||
const workOwned = createCronJob({
|
||||
owner: { agentId: "ops", sessionKey: "agent:ops:main", accountId: "work" },
|
||||
});
|
||||
const context = createCronContext(workOwned);
|
||||
|
||||
const wrongAccount = await invokeCron(
|
||||
"cron.list",
|
||||
{ compact: true },
|
||||
{ context, client: callerClient("ops", "default") },
|
||||
);
|
||||
expect(wrongAccount.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ total: 0, jobs: [] }),
|
||||
undefined,
|
||||
);
|
||||
|
||||
const matchingAccount = await invokeCron(
|
||||
"cron.list",
|
||||
{ compact: true },
|
||||
{ context, client: callerClient("ops", "work") },
|
||||
);
|
||||
expect(matchingAccount.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ total: 1 }),
|
||||
undefined,
|
||||
);
|
||||
|
||||
const legacyContext = createCronContext(
|
||||
createCronJob({ owner: { agentId: "ops", sessionKey: "agent:ops:main" } }),
|
||||
);
|
||||
const legacy = await invokeCron(
|
||||
"cron.list",
|
||||
{ compact: true },
|
||||
{ context: legacyContext, client: callerClient("ops", "other") },
|
||||
);
|
||||
expect(legacy.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ total: 1 }),
|
||||
undefined,
|
||||
);
|
||||
|
||||
const accountOnlyContext = createCronContext(
|
||||
createCronJob({ agentId: "ops", owner: { accountId: "work" } }),
|
||||
);
|
||||
const accountOnlyWrong = await invokeCron(
|
||||
"cron.list",
|
||||
{ compact: true },
|
||||
{ context: accountOnlyContext, client: callerClient("ops", "other") },
|
||||
);
|
||||
expect(accountOnlyWrong.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ total: 0, jobs: [] }),
|
||||
undefined,
|
||||
);
|
||||
const accountOnlyMatch = await invokeCron(
|
||||
"cron.list",
|
||||
{ compact: true },
|
||||
{ context: accountOnlyContext, client: callerClient("ops", "work") },
|
||||
);
|
||||
expect(accountOnlyMatch.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ total: 1 }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit declaration ownership for operator callers", async () => {
|
||||
const owner = { agentId: "ops", sessionKey: "agent:ops:main" };
|
||||
const { context, respond } = await invokeCronAdd(
|
||||
|
||||
@@ -26,7 +26,7 @@ type CreateOpenClawCodingToolsArg = {
|
||||
workspaceDir?: string;
|
||||
cwd?: string;
|
||||
wrapBeforeToolCallHook?: boolean;
|
||||
scheduledToolPolicy?: { ownerSessionKey: string };
|
||||
scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string };
|
||||
};
|
||||
|
||||
type LazyExecToolDefaults = {
|
||||
@@ -161,7 +161,10 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
|
||||
surface: "loopback",
|
||||
excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"],
|
||||
mediatedToolNames: ["write"],
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:qa-channel:group:ops" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:main:qa-channel:group:ops",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tools.map((tool) => tool.name)).toContain("write");
|
||||
@@ -173,7 +176,10 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
|
||||
workspaceDir: "/workspace",
|
||||
cwd: "/workspace/task",
|
||||
wrapBeforeToolCallHook: false,
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:qa-channel:group:ops" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:main:qa-channel:group:ops",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(hoisted.createLazyExecToolMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WORKER_LAUNCH_V2_PROTOCOL_FEATURE } from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import {
|
||||
isUnavailableEnvironment,
|
||||
type WorkerActiveDispatchPlacement,
|
||||
@@ -12,6 +13,15 @@ import {
|
||||
} from "./placement-dispatch-pending-results.js";
|
||||
import type { WorkerEnvironmentService } from "./service.js";
|
||||
|
||||
function supportsWorkerLaunchV2(environment: ReturnType<WorkerEnvironmentService["get"]>): boolean {
|
||||
// A persisted bundle hash can still match a pre-v2 worker when current bundle preparation fails.
|
||||
// Require the admitted receipt so restart recovery never revives an incompatible launch contract.
|
||||
return (
|
||||
environment?.bootstrapReceipt?.protocolFeatures.includes(WORKER_LAUNCH_V2_PROTOCOL_FEATURE) ===
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function sameActiveEnvironment(
|
||||
placement: WorkerActiveDispatchPlacement | WorkerDrainingDispatchPlacement,
|
||||
environment: ReturnType<WorkerEnvironmentService["get"]>,
|
||||
@@ -25,6 +35,7 @@ function sameActiveEnvironment(
|
||||
environment.ownerEpoch === placement.activeOwnerEpoch &&
|
||||
placement.workerBundleHash &&
|
||||
environment.bootstrapReceipt?.bundleHash === placement.workerBundleHash &&
|
||||
supportsWorkerLaunchV2(environment) &&
|
||||
environment.attachedSessionIds.length === 1 &&
|
||||
environment.attachedSessionIds[0] === placement.sessionId,
|
||||
);
|
||||
@@ -120,6 +131,7 @@ export function createPlacementRecoveryActions(deps: PlacementRecoveryDeps) {
|
||||
environment &&
|
||||
expectedBundle &&
|
||||
environment.bootstrapReceipt?.bundleHash === expectedBundle &&
|
||||
supportsWorkerLaunchV2(environment) &&
|
||||
hasSyncedWorkspace;
|
||||
if (!canResume) {
|
||||
const error = new Error("Interrupted worker dispatch cannot safely resume");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { WorkerAdmissionHandshake } from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import {
|
||||
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
|
||||
type WorkerAdmissionHandshake,
|
||||
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import type { WorkerProfile, WorkerSshEndpoint } from "../../plugins/types.js";
|
||||
import type { WorkerDispatchEnvironmentService } from "./placement-dispatch-failure.js";
|
||||
import type { createWorkerPlacementDispatchService } from "./placement-dispatch.js";
|
||||
@@ -86,7 +89,7 @@ export function createDispatchEnvironmentFixtures() {
|
||||
const bootstrapReceipt: WorkerAdmissionHandshake = {
|
||||
bundleHash: BUNDLE_HASH,
|
||||
openclawVersion: "2026.7.2",
|
||||
protocolFeatures: [],
|
||||
protocolFeatures: [WORKER_LAUNCH_V2_PROTOCOL_FEATURE],
|
||||
};
|
||||
const sshEndpoint: WorkerSshEndpoint = {
|
||||
host: "worker.example.test",
|
||||
|
||||
@@ -373,6 +373,15 @@ export function createHarness(
|
||||
markEnvironmentAttachments: (attachedSessionIds: string[]) => {
|
||||
currentEnvironment = { ...attached, attachedSessionIds };
|
||||
},
|
||||
markEnvironmentProtocolFeatures: (protocolFeatures: string[]) => {
|
||||
if (!currentEnvironment?.bootstrapReceipt) {
|
||||
throw new Error("worker environment fixture has no bootstrap receipt");
|
||||
}
|
||||
currentEnvironment = {
|
||||
...currentEnvironment,
|
||||
bootstrapReceipt: { ...currentEnvironment.bootstrapReceipt, protocolFeatures },
|
||||
};
|
||||
},
|
||||
service,
|
||||
ready,
|
||||
attached,
|
||||
|
||||
@@ -458,6 +458,23 @@ describe("worker placement dispatch", () => {
|
||||
expect(harness.environments.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reclaims an active pre-v2 worker instead of adopting its stale launch contract", async () => {
|
||||
const harness = createHarness(placementStore);
|
||||
await harness.environments.attachSession({
|
||||
environmentId: harness.ready.environmentId,
|
||||
ownerEpoch: harness.ready.ownerEpoch,
|
||||
sessionId: REQUEST.sessionId,
|
||||
});
|
||||
harness.placements.seedActive(harness.attached.ownerEpoch);
|
||||
harness.markEnvironmentProtocolFeatures([]);
|
||||
|
||||
await harness.service.reconcile();
|
||||
|
||||
expect(harness.placements.current()).toMatchObject({ state: "reclaimed" });
|
||||
expect(harness.environments.startTunnel).not.toHaveBeenCalled();
|
||||
expect(harness.environments.destroy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reclaims an active placement whose environment is already terminal after restart", async () => {
|
||||
const harness = createHarness(placementStore);
|
||||
harness.placements.seedActive(harness.attached.ownerEpoch);
|
||||
@@ -580,6 +597,21 @@ describe("worker placement dispatch", () => {
|
||||
expect(harness.environments.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tears down a starting pre-v2 worker instead of resuming its stale launch contract", async () => {
|
||||
const harness = createHarness(placementStore);
|
||||
harness.placements.seedStarting();
|
||||
harness.markEnvironmentProtocolFeatures([]);
|
||||
|
||||
await harness.service.reconcile();
|
||||
|
||||
expect(harness.placements.current()).toMatchObject({
|
||||
state: "failed",
|
||||
recoveryError: "Interrupted worker dispatch cannot safely resume",
|
||||
});
|
||||
expect(harness.environments.startTunnel).not.toHaveBeenCalled();
|
||||
expect(harness.environments.destroy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("finishes an interrupted drain through reconciliation before failure", async () => {
|
||||
const harness = createHarness(placementStore);
|
||||
harness.placements.seedDraining(harness.attached.ownerEpoch);
|
||||
|
||||
@@ -60,7 +60,10 @@ describe("resolveWorkerToolAuthority", () => {
|
||||
messageProvider: "whatsapp",
|
||||
senderId: "guest",
|
||||
toolsAllow: ["read", "write", "exec"],
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:main:whatsapp:group:team",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
}),
|
||||
).toEqual(["read", "write", "apply_patch"]);
|
||||
expect(
|
||||
@@ -85,7 +88,10 @@ describe("resolveWorkerToolAuthority", () => {
|
||||
},
|
||||
messageProvider: "whatsapp",
|
||||
toolsAllow: ["write"],
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:main:whatsapp:group:team",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,10 @@ describe("assertSupportedTurn", () => {
|
||||
},
|
||||
},
|
||||
toolsAllow: ["write"],
|
||||
scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" },
|
||||
scheduledToolPolicy: {
|
||||
ownerSessionKey: "agent:main:discord:group:ops",
|
||||
ownerAccountId: "default",
|
||||
},
|
||||
} as SessionPlacementTurnParams),
|
||||
).toEqual({ provider: "openai", model: "gpt-5.4" });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user