fix(cron): preserve Codex app authority

This commit is contained in:
joshavant
2026-08-10 23:38:56 -05:00
committed by Josh Avant
parent 38fabc4e03
commit bb873c8806
61 changed files with 2905 additions and 234 deletions
+12
View File
@@ -283,6 +283,18 @@ Throws, timeouts, exhausted tool budgets, invalid results, and `nextCheck` witho
## Execution styles
### Codex apps in scheduled automations
Codex-created automations can retain the app IDs and permission ceiling
available to the authenticated creator thread. At execution, OpenClaw requires
the same prepared Codex profile and account, then narrows the stored cap against
current app policy. Revoked apps, account/runtime changes, and interactive
approval requirements fail closed with a recovery message; they never fall
back to broader or different credentials. Older jobs without a captured app
envelope continue their ordinary non-app behavior; recreate or reauthorize one
only when it needs Codex app access. See
[Native Codex plugins](/plugins/codex-native-plugins#scheduled-automations).
| Style | `--session` value | Runs in | Best for |
| --------------- | ------------------- | ------------------------- | ------------------------------- |
| Main session | `main` | Dedicated automation lane | Reminders, system events |
+25
View File
@@ -140,6 +140,31 @@ app set automatically. Run `/new` or `/reset` to refresh the current
conversation. A gateway restart is not required for plugin enable/disable
changes.
## Scheduled automations
When an authenticated owner creates an automation from a Codex turn, OpenClaw
captures the app IDs and approval limits callable on that exact Codex thread.
The stored authority is bound to the creator's prepared Codex profile and
account. Scheduled runs intersect that cap with current Codex policy and app
availability. They never gain new app IDs or a broader destructive,
open-world, or approval ceiling. Tools added later within an already captured
app may run only when both the stored ceiling and current policy allow them.
Scheduled app calls are unattended. Only actions explicitly allowed both when
the job was created and when it runs can proceed without a prompt. An action
that still requires approval or elicitation is declined. A changed account,
runtime, revoked app, narrower policy, or unavailable inventory stops before
app execution and reports how to restore access or reauthorize the automation.
Model fallbacks cannot move this authority to another runtime or account.
Jobs created before app authority capture may keep their ordinary OpenClaw
tool cap and continue non-app work, but cannot recover Codex app access
automatically. Recreate or reauthorize only a job that needs app access, from a
fresh authenticated owner turn. See
[Automations](/automation/cron-jobs#codex-apps-in-scheduled-automations).
Explicitly replacing a job's `toolsAllow` cap also clears its captured app
authority; the next run reports that app access requires reauthorization.
## Manage plugins from chat
`/codex plugins` inspects or changes configured native Codex plugins from the
@@ -191,6 +191,35 @@ describe("Codex app-server approval bridge", () => {
}));
});
it("keeps unrelated command approval policy unchanged for scheduled app authority", async () => {
const params = {
...createParams(),
trigger: "cron",
scheduledRuntimeAuthority: {
version: 1,
runtimeId: "codex",
namespace: "codex.apps",
payload: { version: 1 },
},
} as EmbeddedRunAttemptParams;
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
...codexTestTurnIds(),
itemId: "scheduled-command",
command: "dangerous-command",
},
paramsForRun: params,
...codexTestTurnIds(),
autoApprove: true,
});
expect(result).toEqual({ decision: "acceptForSession" });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
expect(mockRunBeforeToolCallHook).toHaveBeenCalled();
});
it("auto-accepts app-server command approvals in yolo mode without opening plugin approvals", async () => {
const params = createParams();
@@ -101,7 +101,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
});
return buildApprovalResponse(params.method, context.requestParams, outcome);
};
try {
const policyOutcome = await runOpenClawToolPolicyForApprovalRequest({
method: params.method,
@@ -67,6 +67,7 @@ import {
releaseCodexSandboxExecServerEnvironment,
type CodexSandboxExecEnvironment,
} from "./sandbox-exec-server.js";
import { buildScheduledCodexAppAuthorityInputFingerprint } from "./scheduled-app-authority.js";
import type { CodexAppServerBindingStore } from "./session-binding.js";
import {
clearSharedCodexAppServerClientIfCurrent,
@@ -204,6 +205,7 @@ export async function startCodexAttemptThread(params: {
const pluginStartupPolicy = resolveCodexPluginThreadConfigStartupPolicy({
pluginConfig: params.pluginConfig,
nativeToolSurfaceEnabled: params.nativeToolSurfaceEnabled,
scheduledRuntimeAuthority: params.buildAttemptParams().scheduledRuntimeAuthority,
});
const {
pluginThreadConfigRequired,
@@ -353,12 +355,18 @@ export async function startCodexAttemptThread(params: {
appServerVersion: activeStartupClient.getServerVersion(),
runtimeIdentity: startupRuntimeIdentity,
});
const pluginThreadConfigInputFingerprint = pluginThreadConfigRequired
const basePluginThreadConfigInputFingerprint = pluginThreadConfigRequired
? buildCodexPluginThreadConfigInputFingerprint({
pluginConfig: pluginThreadConfigPluginConfig,
appCacheKey: pluginAppCacheKey,
})
: undefined;
const pluginThreadConfigInputFingerprint = basePluginThreadConfigInputFingerprint
? buildScheduledCodexAppAuthorityInputFingerprint(
basePluginThreadConfigInputFingerprint,
attemptParams.scheduledRuntimeAuthority,
)
: undefined;
embeddedAgentLog.debug(
"codex plugin thread config eligibility",
buildCodexPluginThreadConfigEligibilityLogData({
@@ -502,6 +510,7 @@ export async function startCodexAttemptThread(params: {
client: activeStartupClient,
configCwd: startupExecutionCwd,
appCacheKey: pluginAppCacheKey,
scheduledRuntimeAuthority: attemptParams.scheduledRuntimeAuthority,
})
: undefined,
}) satisfies Parameters<typeof startOrResumeThread>[0];
@@ -719,11 +728,5 @@ function shouldClearSharedClientAfterStartupFailure(params: {
error: unknown;
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
}): boolean {
if (!(params.error instanceof Error)) {
return !params.spawnedBy;
}
if (isCodexAppServerBrokenPipeError(params.error)) {
return true;
}
return !params.spawnedBy;
return isCodexAppServerBrokenPipeError(params.error) || !params.spawnedBy;
}
@@ -261,10 +261,12 @@ describe("bridgeCodexAppServerStartOptions", () => {
await writeCodexCliAuthFile(codexHome);
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
vi.stubEnv("CODEX_HOME", "");
vi.stubEnv("HOME", path.join(agentDir, "empty-home"));
await expect(
bridgeCodexAppServerStartOptions({
startOptions: createStartOptions(),
startOptions: createStartOptions({ headers: {} }),
agentDir,
agentId: "research",
authRequirement,
@@ -1713,6 +1715,36 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("exposes only a genuine credential account id for scheduled authorization identity", async () => {
const base = {
type: "oauth" as const,
provider: "openai",
access: "subscription-token",
refresh: "refresh-token",
expires: Date.now() + 60 * 60_000,
email: "operator@example.test",
};
const withAccount = await resolveCodexAppServerPreparedAuthProfileSnapshot({
agentDir: "/tmp/openclaw-agent",
authProfileId: "openai:work",
authProfileStore: {
version: 1,
profiles: { "openai:work": { ...base, accountId: "account-123" } },
},
});
const emailOnly = await resolveCodexAppServerPreparedAuthProfileSnapshot({
agentDir: "/tmp/openclaw-agent",
authProfileId: "openai:email-only",
authProfileStore: {
version: 1,
profiles: { "openai:email-only": base },
},
});
expect(withAccount?.chatgptAccountId).toBe("account-123");
expect(emailOnly).not.toHaveProperty("chatgptAccountId");
});
it("applies a normal OpenAI API-key profile as a Codex app-server backup", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "apiKey" }));
@@ -2260,6 +2292,8 @@ describe("bridgeCodexAppServerStartOptions", () => {
});
vi.stubEnv("CODEX_API_KEY", "codex-env-api-key");
vi.stubEnv("OPENAI_API_KEY", "openai-env-api-key");
vi.stubEnv("CODEX_HOME", path.join(agentDir, "empty-codex-home"));
vi.stubEnv("HOME", path.join(agentDir, "empty-home"));
try {
await applyCodexAppServerAuthProfile({
client: { request } as never,
@@ -2459,6 +2493,8 @@ describe("bridgeCodexAppServerStartOptions", () => {
});
vi.stubEnv("CODEX_API_KEY", "codex-env-api-key");
vi.stubEnv("OPENAI_API_KEY", "openai-env-api-key");
vi.stubEnv("CODEX_HOME", path.join(agentDir, "empty-codex-home"));
vi.stubEnv("HOME", path.join(agentDir, "empty-home"));
try {
await applyCodexAppServerAuthProfile({
client: { request } as never,
+13 -3
View File
@@ -240,6 +240,8 @@ export function resolveCodexAppServerAuthProfileStore(params: {
type CodexAppServerPreparedAuthProfileSnapshot = {
loginParams: CodexLoginAccountParams;
secretFreeCacheKey: string;
/** Genuine ChatGPT principal id; email/profile fallbacks are not authorization identity. */
chatgptAccountId?: string;
};
export type CodexAppServerPreparedAuth =
@@ -304,7 +306,12 @@ export async function resolveCodexAppServerPreparedAuthProfileSnapshot(params: {
(credential.type === "token" || !stableChatgptAccountId)
? `${accountId}:${fingerprintTokenAuthProfileCacheKey(loginParams.accessToken)}`
: accountId;
return { loginParams, secretFreeCacheKey };
const chatgptAccountId = resolveExplicitChatgptAccountId(credential);
return {
loginParams,
secretFreeCacheKey,
...(chatgptAccountId ? { chatgptAccountId } : {}),
};
}
/** Maps one prepared route to one mutually exclusive app-server auth handoff. */
@@ -1136,12 +1143,15 @@ function resolveChatgptAccountId(profileId: string, credential: AuthProfileCrede
}
function resolveStableChatgptAccountId(credential: AuthProfileCredential): string | undefined {
return resolveExplicitChatgptAccountId(credential) ?? (credential.email?.trim() || undefined);
}
function resolveExplicitChatgptAccountId(credential: AuthProfileCredential): string | undefined {
if ("accountId" in credential && typeof credential.accountId === "string") {
const accountId = credential.accountId.trim();
if (accountId) {
return accountId;
}
}
const email = credential.email?.trim();
return email || undefined;
return undefined;
}
@@ -19,7 +19,7 @@ import {
type RuntimeToolSchemaDiagnostic,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { runWithCronCreatorAuthorityResolver } from "openclaw/plugin-sdk/codex-mcp-projection";
import { runWithCronCreatorAuthorityCapabilityResolver } from "openclaw/plugin-sdk/codex-mcp-projection";
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js";
import { dynamicToolBuildState } from "./dynamic-tool-build-state.js";
@@ -98,7 +98,7 @@ type DynamicToolBuildParams = {
cronCreatorToolAllowlistRef?: OpenClawCodingToolsOptions["cronCreatorToolAllowlistRef"];
cronCreatorToolAllowlistCaptureRef?: OpenClawCodingToolsOptions["cronCreatorToolAllowlistCaptureRef"];
resolveCronCreatorToolAuthority?: Parameters<
typeof runWithCronCreatorAuthorityResolver
typeof runWithCronCreatorAuthorityCapabilityResolver
>[0]["resolve"];
cronCreatorAuthorityUnavailableReason?: OpenClawCodingToolsOptions["cronCreatorAuthorityUnavailableReason"];
forceHeartbeatTool?: boolean;
@@ -355,7 +355,8 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
{ cwd: input.effectiveCwd ?? input.effectiveWorkspace },
);
const allTools = input.resolveCronCreatorToolAuthority
? runWithCronCreatorAuthorityResolver({
? runWithCronCreatorAuthorityCapabilityResolver({
capability: params.cronCreatorAuthorityCapability,
runId: params.runId,
resolve: input.resolveCronCreatorToolAuthority,
run: buildOpenClawCodingTools,
@@ -256,6 +256,56 @@ describe("Codex app-server elicitation bridge", () => {
vi.restoreAllMocks();
});
it("declines app elicitations for scheduled app authority", async () => {
const params = {
...createParams(),
trigger: "cron",
scheduledRuntimeAuthority: {
version: 1,
runtimeId: "codex",
namespace: "codex.apps",
payload: { version: 1 },
},
} as EmbeddedRunAttemptParams;
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildPluginApprovalElicitation(),
paramsForRun: params,
...codexTestTurnIds(),
pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
it("keeps unrelated Computer Use elicitation policy unchanged", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-computer-use", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-computer-use", decision: "allow-once" });
const params = {
...createParams(),
trigger: "cron",
scheduledRuntimeAuthority: {
version: 1,
runtimeId: "codex",
namespace: "codex.apps",
payload: { version: 1 },
},
} as EmbeddedRunAttemptParams;
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildComputerUseApprovalElicitation(),
paramsForRun: params,
...codexTestTurnIds(),
pluginAppPolicyContext: createPluginAppPolicyContext({ apps: [] }),
computerUseMcpServerName: "computer-use",
});
expect(result).toEqual({ action: "accept", content: null, _meta: null });
expect(mockCallGatewayTool).toHaveBeenCalledTimes(2);
});
it("routes MCP tool approval elicitations through plugin approvals", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-1", status: "accepted" })
@@ -92,6 +92,10 @@ export async function handleCodexAppServerElicitationRequest(params: {
pluginAppPolicyContext: params.pluginAppPolicyContext,
});
if (pluginResolution.kind !== "not_plugin") {
if (params.paramsForRun.trigger === "cron" && params.paramsForRun.scheduledRuntimeAuthority) {
logPluginElicitationDecline("scheduled_authority_non_interactive", requestParams);
return declineElicitationResponse();
}
if (pluginResolution.kind === "decline") {
logPluginElicitationDecline(pluginResolution.reason, requestParams);
return declineElicitationResponse();
@@ -1,3 +1,7 @@
import {
AgentHarnessPreflightError,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
/** Enforces one bounded startup budget across Codex plugin config discovery. */
import {
defaultCodexAppInventoryCache,
@@ -10,7 +14,10 @@ import {
type ResolvedCodexPluginsPolicy,
} from "./config.js";
import { disableCodexPluginThreadConfig } from "./dynamic-tool-build.js";
import { resolveRecoverableCodexPluginConfigKeys } from "./plugin-inventory.js";
import {
resolveRecoverableCodexPluginConfigKeys,
type CodexPluginRuntimeRequest,
} from "./plugin-inventory.js";
import {
defaultCodexPluginMetadataCache,
type CodexPluginMetadataCache,
@@ -21,6 +28,12 @@ import {
shouldBuildCodexPluginThreadConfig,
type CodexPluginThreadConfig,
} from "./plugin-thread-config.js";
import {
intersectCodexPluginThreadConfigWithScheduledAuthority,
readCurrentCodexScheduledAppPolicy as readCurrentCodexScheduledAppPolicyShared,
} from "./scheduled-app-authority.js";
import type { CurrentCodexScheduledAppPolicy } from "./scheduled-app-authority.js";
import { withAbortableTimeout } from "./timeout.js";
const CODEX_PLUGIN_THREAD_CONFIG_MAX_TIMEOUT_MS = 60_000;
const CODEX_PLUGIN_THREAD_CONFIG_TIMEOUT_DIVISOR = 4;
@@ -39,6 +52,11 @@ type BuildCodexPluginThreadConfigWithinDeadlineParams = Omit<
requestTimeoutMs: number;
signal: AbortSignal;
request: CodexPluginThreadConfigDeadlineRequest;
failClosedOnTimeout?: boolean;
transform?: (
config: CodexPluginThreadConfig,
request: CodexPluginRuntimeRequest,
) => Promise<CodexPluginThreadConfig>;
};
class CodexPluginThreadConfigDeadlineError extends Error {
@@ -52,14 +70,18 @@ class CodexPluginThreadConfigDeadlineError extends Error {
export function resolveCodexPluginThreadConfigStartupPolicy(params: {
pluginConfig: CodexPluginConfig;
nativeToolSurfaceEnabled: boolean;
scheduledRuntimeAuthority?: EmbeddedRunAttemptParams["scheduledRuntimeAuthority"];
}) {
const pluginThreadConfigRequired =
!params.nativeToolSurfaceEnabled || shouldBuildCodexPluginThreadConfig(params.pluginConfig);
Boolean(params.scheduledRuntimeAuthority) ||
!params.nativeToolSurfaceEnabled ||
shouldBuildCodexPluginThreadConfig(params.pluginConfig);
// Restricted runs still need a config so thread/start carries an explicit
// apps._default denial patch without app inventory discovery.
const pluginThreadConfigPluginConfig = params.nativeToolSurfaceEnabled
? params.pluginConfig
: disableCodexPluginThreadConfig(params.pluginConfig);
const pluginThreadConfigPluginConfig =
params.nativeToolSurfaceEnabled || params.scheduledRuntimeAuthority
? params.pluginConfig
: disableCodexPluginThreadConfig(params.pluginConfig);
const resolvedPluginPolicy = pluginThreadConfigRequired
? resolveCodexPluginsPolicy(pluginThreadConfigPluginConfig)
: undefined;
@@ -80,34 +102,45 @@ export function resolveCodexPluginThreadConfigStartupPolicy(params: {
async function buildCodexPluginThreadConfigWithinDeadline(
params: BuildCodexPluginThreadConfigWithinDeadlineParams,
): Promise<CodexPluginThreadConfig> {
const { requestTimeoutMs, signal, request, ...buildParams } = params;
const { requestTimeoutMs, signal, request, failClosedOnTimeout, transform, ...buildParams } =
params;
const timeoutMs = resolveCodexPluginThreadConfigTimeoutMs(requestTimeoutMs);
// One deadline owns the whole config build; every RPC gets only the remaining
// budget so discovery cannot consume one full request timeout per call.
const deadlineMs = Date.now() + timeoutMs;
const boundedRequest: CodexPluginRuntimeRequest = (method, requestParams) => {
const remainingTimeoutMs = deadlineMs - Date.now();
if (remainingTimeoutMs <= 0) {
throw new CodexPluginThreadConfigDeadlineError();
}
return request(method, requestParams, {
timeoutMs: remainingTimeoutMs,
signal,
});
};
try {
return await waitForCodexPluginThreadConfigBuild({
return await withAbortableTimeout({
signal,
timeoutMs,
build: () =>
buildCodexPluginThreadConfig({
promise: (async () => {
const config = await buildCodexPluginThreadConfig({
...buildParams,
request: (method, requestParams) => {
const remainingTimeoutMs = deadlineMs - Date.now();
if (remainingTimeoutMs <= 0) {
throw new CodexPluginThreadConfigDeadlineError();
}
return request(method, requestParams, {
timeoutMs: remainingTimeoutMs,
signal,
});
},
}),
request: boundedRequest,
});
return transform ? await transform(config, boundedRequest) : config;
})(),
timeoutMessage: "Codex plugin thread config deadline elapsed",
createTimeoutError: () => new CodexPluginThreadConfigDeadlineError(),
});
} catch (error) {
if (signal.aborted || !isCodexPluginThreadConfigTimeoutError(error)) {
throw error;
}
if (failClosedOnTimeout) {
throw new AgentHarnessPreflightError(
`Scheduled Codex app policy verification exceeded its ${timeoutMs} ms startup budget. No app tools were executed. Retry after Codex app inventory is responsive, or reauthorize the automation.`,
);
}
return buildCodexPluginThreadConfigTimeoutFallback({
pluginConfig: buildParams.pluginConfig,
appCacheKey: buildParams.appCacheKey,
@@ -116,51 +149,6 @@ async function buildCodexPluginThreadConfigWithinDeadline(
}
}
function waitForCodexPluginThreadConfigBuild(params: {
signal: AbortSignal;
timeoutMs: number;
build: () => Promise<CodexPluginThreadConfig>;
}): Promise<CodexPluginThreadConfig> {
if (params.signal.aborted) {
return Promise.reject(resolveAbortReason(params.signal));
}
return new Promise((resolve, reject) => {
let settled = false;
const finish = () => {
if (settled) {
return false;
}
settled = true;
clearTimeout(timer);
params.signal.removeEventListener("abort", onAbort);
return true;
};
const resolveOnce = (config: CodexPluginThreadConfig) => {
if (finish()) {
resolve(config);
}
};
const rejectOnce = (error: unknown) => {
if (finish()) {
reject(error instanceof Error ? error : new Error(String(error)));
}
};
const onAbort = () => rejectOnce(resolveAbortReason(params.signal));
const timer = setTimeout(
() => rejectOnce(new CodexPluginThreadConfigDeadlineError()),
params.timeoutMs,
);
params.signal.addEventListener("abort", onAbort, { once: true });
params.build().then(resolveOnce, rejectOnce);
});
}
function resolveAbortReason(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error("Codex plugin thread config aborted");
}
/** Creates the recovery metadata and bounded builder used by thread startup. */
export function createCodexPluginThreadConfigStartupProvider(params: {
inputFingerprint: string | undefined;
@@ -174,6 +162,7 @@ export function createCodexPluginThreadConfigStartupProvider(params: {
appCache?: CodexAppInventoryCache;
appCacheKey: string;
metadataCache?: CodexPluginMetadataCache;
scheduledRuntimeAuthority?: EmbeddedRunAttemptParams["scheduledRuntimeAuthority"];
}) {
const {
client,
@@ -187,6 +176,7 @@ export function createCodexPluginThreadConfigStartupProvider(params: {
const metadataCache = configuredMetadataCache ?? defaultCodexPluginMetadataCache;
return {
enabled: true,
requiresCurrentPolicyCheck: Boolean(params.scheduledRuntimeAuthority),
inputFingerprint,
enabledPluginConfigKeys,
accountAppRecoveryEnabled: policy?.allowAllPlugins,
@@ -198,16 +188,45 @@ export function createCodexPluginThreadConfigStartupProvider(params: {
configCwd: params.configCwd,
})
: undefined,
build: () =>
buildCodexPluginThreadConfigWithinDeadline({
build: async (buildOptions?: { threadId?: string }) => {
const config = await buildCodexPluginThreadConfigWithinDeadline({
...buildParams,
appCache: appCache ?? defaultCodexAppInventoryCache,
metadataCache,
failClosedOnTimeout: Boolean(params.scheduledRuntimeAuthority),
transform: params.scheduledRuntimeAuthority
? async (builtConfig, request) =>
intersectCodexPluginThreadConfigWithScheduledAuthority(
builtConfig,
params.scheduledRuntimeAuthority,
await readCurrentCodexScheduledAppPolicy(
request,
params.configCwd,
buildOptions?.threadId,
),
)
: undefined,
request: (method, requestParams, options) => client.request(method, requestParams, options),
}),
});
return params.scheduledRuntimeAuthority && params.inputFingerprint
? { ...config, inputFingerprint: params.inputFingerprint }
: config;
},
};
}
async function readCurrentCodexScheduledAppPolicy(
request: CodexPluginRuntimeRequest,
cwd: string | undefined,
threadId: string | undefined,
): Promise<CurrentCodexScheduledAppPolicy> {
return await readCurrentCodexScheduledAppPolicyShared({
request,
configCwd: cwd,
threadId,
});
}
function resolveCodexPluginThreadConfigTimeoutMs(requestTimeoutMs: number): number {
const finiteRequestTimeoutMs =
Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0
@@ -93,6 +93,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
allowOpenWorld: true,
destructiveApprovalMode: "allow",
mcpServerNames: ["google-calendar"],
});
@@ -856,6 +857,7 @@ describe("Codex plugin thread config", () => {
source: "account",
appName: "ChatGPT Meetings",
allowDestructiveActions: false,
allowOpenWorld: true,
destructiveApprovalMode: "deny",
mcpServerNames: [],
},
@@ -863,6 +865,7 @@ describe("Codex plugin thread config", () => {
source: "account",
appName: "disabled-account-app",
allowDestructiveActions: false,
allowOpenWorld: true,
destructiveApprovalMode: "deny",
mcpServerNames: [],
},
@@ -870,6 +873,7 @@ describe("Codex plugin thread config", () => {
source: "account",
appName: "Slack",
allowDestructiveActions: false,
allowOpenWorld: true,
destructiveApprovalMode: "deny",
mcpServerNames: [],
},
@@ -1491,6 +1495,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
allowOpenWorld: true,
destructiveApprovalMode: "allow",
mcpServerNames: [],
});
@@ -1983,6 +1988,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
allowOpenWorld: true,
destructiveApprovalMode: "allow",
mcpServerNames: [],
});
@@ -2072,6 +2078,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
allowOpenWorld: true,
destructiveApprovalMode: "allow",
mcpServerNames: [],
});
@@ -48,6 +48,7 @@ export type PluginAppPolicyContextEntry = {
marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"];
pluginName: string;
allowDestructiveActions: boolean;
allowOpenWorld?: boolean;
destructiveApprovalMode?: CodexPluginDestructiveApprovalMode;
mcpServerNames: string[];
};
@@ -57,6 +58,7 @@ type AccountAppPolicyContextEntry = {
source: "account";
appName: string;
allowDestructiveActions: boolean;
allowOpenWorld?: boolean;
destructiveApprovalMode?: CodexPluginDestructiveApprovalMode;
mcpServerNames: string[];
};
@@ -379,6 +381,7 @@ export async function buildCodexPluginThreadConfig(
marketplaceName: record.policy.marketplaceName,
pluginName: record.policy.pluginName,
allowDestructiveActions: record.policy.allowDestructiveActions,
allowOpenWorld: true,
destructiveApprovalMode: record.policy.destructiveApprovalMode,
mcpServerNames: [...(record.detail?.mcpServers ?? [])].toSorted(),
};
@@ -425,6 +428,7 @@ export async function buildCodexPluginThreadConfig(
source: "account",
appName: app.name,
allowDestructiveActions: policy.allowDestructiveActions,
allowOpenWorld: true,
destructiveApprovalMode: policy.destructiveApprovalMode,
mcpServerNames: [],
};
@@ -551,7 +555,7 @@ export function buildCodexPluginAppsConfigPatchFromPolicyContext(
apps[appId] = {
enabled: true,
destructive_enabled: policy.allowDestructiveActions,
open_world_enabled: true,
open_world_enabled: policy.allowOpenWorld !== false,
default_tools_approval_mode: "auto",
...(policy.destructiveApprovalMode === "ask" ? { approvals_reviewer: "user" } : {}),
};
@@ -559,7 +563,7 @@ export function buildCodexPluginAppsConfigPatchFromPolicyContext(
return { apps };
}
function buildPluginAppPolicyContext(
export function buildPluginAppPolicyContext(
apps: Record<string, CodexAppPolicyContextEntry>,
pluginAppIds: Record<string, string[]>,
): PluginAppPolicyContext {
@@ -42,6 +42,9 @@ export async function cleanupCodexAttempt(
} = lifecycle;
const { codexModelCallDiagnostics } = requestRuntime;
const { activeTurnId, abortListener, handle, freezeRunTerminalOutcome } = activeTurn;
// Exact-thread cron authority exists only while this creator turn owns the
// live client/thread. Retained model callbacks must fail after cleanup begins.
prompt.context.attemptTools.scheduledAppAuthoritySourceRef.current = undefined;
try {
steeringQueueRef.current?.cancel();
if (params.isFinalFallbackAttempt !== false) {
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { assertScheduledCodexAppAuthorityRuntime } from "./scheduled-app-authority.js";
import { canResolveScheduledConfiguredMcpCreatorAuthority } from "./scheduled-configured-mcp-authority.js";
const eligible = {
@@ -39,3 +40,89 @@ describe("canResolveScheduledConfiguredMcpCreatorAuthority", () => {
);
});
});
const scheduledAuthority = {
version: 1 as const,
runtimeId: "codex",
namespace: "codex.apps",
payload: {
version: 1,
auth: { profileId: "openai:work", accountId: "account-1" },
apps: [],
},
};
function scheduledConnection(overrides: Record<string, unknown> = {}) {
return {
usesSupervisionConnection: false,
appServer: { start: { homeScope: "agent" } },
startupPreparedAuth: {
kind: "profile",
profileId: "openai:work",
snapshot: {
loginParams: {
type: "chatgptAuthTokens",
accessToken: "token",
chatgptAccountId: "account-1",
},
chatgptAccountId: "account-1",
secretFreeCacheKey: "profile-key",
},
},
...overrides,
} as never;
}
describe("assertScheduledCodexAppAuthorityRuntime", () => {
it("admits the exact cron prepared-profile principal", () => {
expect(() =>
assertScheduledCodexAppAuthorityRuntime(scheduledConnection(), {
trigger: "cron",
scheduledRuntimeAuthority: scheduledAuthority,
}),
).not.toThrow();
});
it.each([
["ordinary turn", {}, { trigger: "user" }],
["supervision", { usesSupervisionConnection: true }, {}],
["user-scoped home", { appServer: { start: { homeScope: "user" } } }, {}],
[
"different profile",
{
startupPreparedAuth: {
kind: "profile",
profileId: "openai:other",
snapshot: {
loginParams: { type: "chatgptAuthTokens" },
chatgptAccountId: "account-1",
},
},
},
{},
],
[
"different account",
{
startupPreparedAuth: {
kind: "profile",
profileId: "openai:work",
snapshot: {
loginParams: { type: "chatgptAuthTokens" },
chatgptAccountId: "account-2",
},
},
},
{},
],
["API-key route", { startupPreparedAuth: { kind: "api-key", apiKey: "key" } }, {}],
] as const)("fails closed for %s", (_name, connectionOverrides, paramsOverrides) => {
expect(() =>
assertScheduledCodexAppAuthorityRuntime(scheduledConnection(connectionOverrides), {
trigger: "cron",
scheduledRuntimeAuthority: scheduledAuthority,
...paramsOverrides,
}),
).toThrow(/Reauthorize|Restore the profile/);
});
});
@@ -21,6 +21,10 @@ import {
import { resolveCodexProviderWebSearchSupport } from "./provider-capabilities.js";
import { prewarmCodexAttemptClient } from "./run-attempt-client-prewarm.js";
import type { CodexAttemptConnection } from "./run-attempt-connection.js";
import {
assertScheduledCodexAppAuthorityRuntime,
buildLegacyScheduledCodexAppRecoveryPrompt,
} from "./scheduled-app-authority.js";
import { canResolveScheduledConfiguredMcpCreatorAuthority } from "./scheduled-configured-mcp-authority.js";
import { resolveCodexAppServerThreadModelSelection } from "./thread-lifecycle.js";
import { resolveCodexWebSearchPlan } from "./web-search.js";
@@ -63,6 +67,7 @@ export async function prepareCodexAttemptRuntime(connection: CodexAttemptConnect
config: params.config,
})
: undefined;
assertScheduledCodexAppAuthorityRuntime(connection, params);
const attemptAuthProfileStore = preparedAuthBinding?.authProfileStore ?? params.authProfileStore;
prewarmCodexAttemptClient({
connection,
@@ -107,6 +112,7 @@ export async function prepareCodexAttemptRuntime(connection: CodexAttemptConnect
contextWindow: undefined,
maxTokens: undefined,
} as unknown as EmbeddedRunAttemptParams["model"];
const legacyScheduledAppRecoveryPrompt = buildLegacyScheduledCodexAppRecoveryPrompt(params);
const runtimeParams: EmbeddedRunAttemptParams = usesSupervisionConnection
? {
...paramsWithoutOuterNativeOwnership,
@@ -121,6 +127,13 @@ export async function prepareCodexAttemptRuntime(connection: CodexAttemptConnect
...params,
authProfileStore: attemptAuthProfileStore,
sessionKey: contextSessionKey,
...(legacyScheduledAppRecoveryPrompt
? {
extraSystemPrompt: [params.extraSystemPrompt, legacyScheduledAppRecoveryPrompt]
.filter((value): value is string => Boolean(value?.trim()))
.join("\n\n"),
}
: {}),
...(startupAuthProfileId ? { authProfileId: startupAuthProfileId } : {}),
};
const activeSessionId = params.sessionId;
@@ -172,6 +185,13 @@ export async function prepareCodexAttemptRuntime(connection: CodexAttemptConnect
authenticatedScheduledMode &&
(bundleMcpThreadConfig.staticServerNames.length > 0 ||
mutable.startupBinding?.configuredMcpOwnershipVersion === 1);
const cronCreatorAuthorityCapability = params.cronCreatorAuthorityCapability;
// Senderless local operator RPCs prove freshness with the host-minted exact-run
// capability; do not promote that fact to general sender ownership.
const hasFreshCreatorAuthority =
cronCreatorAuthorityCapability?.active === true &&
cronCreatorAuthorityCapability.runId === params.runId &&
!cronCreatorAuthorityCapability.signal.aborted;
const mayResolveScheduledConfiguredMcpCreatorAuthority =
!authenticatedScheduledMode &&
canResolveScheduledConfiguredMcpCreatorAuthority({
@@ -186,6 +206,7 @@ export async function prepareCodexAttemptRuntime(connection: CodexAttemptConnect
usesSupervisionConnection,
preservesNativeModel: mutable.startupBinding?.preserveNativeModel === true,
senderIsOwner: params.senderIsOwner,
hasFreshCreatorAuthority,
senderId: params.senderId,
inputProvenance: params.inputProvenance,
trustedInternalHandoff: params.trustedInternalHandoff,
@@ -3,12 +3,13 @@ import {
isHostScopedAgentToolActive,
materializeRequesterScopedMcpToolsForHarnessRun,
resolveAgentDir,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
captureFinalCodexCronCreatorToolAllowlist,
materializeStaticMcpToolsForScheduledHarnessRun,
} from "openclaw/plugin-sdk/codex-mcp-projection";
import { shouldAutoApproveCodexAppServerApprovals } from "./config.js";
import { resolveCodexPluginsPolicy, shouldAutoApproveCodexAppServerApprovals } from "./config.js";
import {
buildDynamicTools,
formatCodexDynamicToolBuildStageSummary,
@@ -26,6 +27,10 @@ import {
import { emitCodexAppServerEvent } from "./run-attempt-lifecycle.js";
import type { CodexAttemptRuntime } from "./run-attempt-runtime.js";
import { resolveCodexDynamicToolDirectNames } from "./run-attempt-tools.js";
import {
captureScheduledCodexAppAuthority,
resolveScheduledCodexAppCreatorCaptureDecision,
} from "./scheduled-app-authority.js";
function isAuthorityResolutionOperationAbort(error: unknown, signal: AbortSignal | undefined) {
return signal?.aborted === true && error === signal.reason;
@@ -129,17 +134,50 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) {
const cronCreatorToolAllowlistCaptureRef: {
value?: { version: 1; source: "final-executable-surface" };
} = {};
const scheduledAppAuthoritySourceRef: {
current?: Omit<
Parameters<typeof captureScheduledCodexAppAuthority>[0],
"profileId" | "accountId"
>;
} = {};
const preparedChatgptAuth =
connection.startupPreparedAuth?.kind === "profile" &&
connection.startupPreparedAuth.snapshot?.loginParams.type === "chatgptAuthTokens" &&
connection.startupPreparedAuth.snapshot.chatgptAccountId
? {
profileId: connection.startupPreparedAuth.profileId,
accountId: connection.startupPreparedAuth.snapshot.chatgptAccountId,
}
: undefined;
const appPolicy = resolveCodexPluginsPolicy(pluginConfig);
const codexAppsMayBeVisible =
appPolicy.enabled &&
(appPolicy.allowAllPlugins || appPolicy.pluginPolicies.some((entry) => entry.enabled));
const appCreatorCapture = resolveScheduledCodexAppCreatorCaptureDecision({
appsMayBeVisible: codexAppsMayBeVisible,
authenticatedScheduledMode,
usesSupervisionConnection: connection.usesSupervisionConnection,
homeScope: connection.appServer.start.homeScope,
hasPreparedAccountIdentity: Boolean(preparedChatgptAuth),
});
const codexAppAuthorityUnavailableReason = appCreatorCapture.unavailableReason;
const canResolveScheduledCodexAppAuthority = appCreatorCapture.supported;
const requiresScheduledCodexAppAuthority = appCreatorCapture.required;
const canResolveAnyScheduledCreatorAuthority =
canResolveScheduledConfiguredMcpCreatorAuthority || requiresScheduledCodexAppAuthority;
let toolBridge: ReturnType<typeof createCodexDynamicToolBridge> | undefined;
let creatorAuthorityPromise:
| Promise<{
tools: readonly (string | { name: string; pluginId?: string })[];
provenance: { version: 1; source: "final-executable-surface" };
runtimeAuthority?: NonNullable<EmbeddedRunAttemptParams["scheduledRuntimeAuthority"]>;
}>
| undefined;
let resolveCreatorAuthorityImpl:
| ((options?: { signal?: AbortSignal }) => Promise<{
tools: readonly (string | { name: string; pluginId?: string })[];
provenance: { version: 1; source: "final-executable-surface" };
runtimeAuthority?: NonNullable<EmbeddedRunAttemptParams["scheduledRuntimeAuthority"]>;
}>)
| undefined;
const commonToolParams = {
@@ -168,7 +206,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) {
void emitCodexAppServerEvent(params, event);
},
computerContextEpoch,
...(canResolveScheduledConfiguredMcpCreatorAuthority
...(canResolveAnyScheduledCreatorAuthority
? {
resolveCronCreatorToolAuthority: (options?: { signal?: AbortSignal }) => {
if (!resolveCreatorAuthorityImpl) {
@@ -382,11 +420,54 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) {
// Keep the names for finite intersections, but never certify a partial default cap.
delete cronCreatorToolAllowlistCaptureRef.value;
}
if (canResolveScheduledConfiguredMcpCreatorAuthority) {
if (requiresScheduledCodexAppAuthority) {
// Native apps are not represented in the OpenClaw dynamic-tool list.
// Require the exact-thread resolver before certifying a default cap.
delete cronCreatorToolAllowlistCaptureRef.value;
}
if (canResolveAnyScheduledCreatorAuthority) {
resolveCreatorAuthorityImpl = async (options) => {
options?.signal?.throwIfAborted();
if (codexAppAuthorityUnavailableReason) {
throw new Error(codexAppAuthorityUnavailableReason);
}
if (!toolBridge) {
throw new Error("configured MCP authority resolver lost the active tool bridge");
throw new Error("cron creator authority resolver lost the active tool bridge");
}
const authorityTools: Array<string | { name: string; pluginId?: string }> = [];
const captureRef: {
value?: { version: 1; source: "final-executable-surface" };
} = {};
await captureFinalCodexCronCreatorToolAllowlist(
authorityTools,
captureRef,
toolBridge.availableTools,
);
if (!captureRef.value) {
throw new Error("cron creator authority snapshot did not produce provenance");
}
const appSource = scheduledAppAuthoritySourceRef.current;
const runtimeAuthority =
canResolveScheduledCodexAppAuthority && preparedChatgptAuth
? appSource
? await captureScheduledCodexAppAuthority({
...appSource,
...preparedChatgptAuth,
signal: options?.signal,
})
: (() => {
throw new Error(
"Codex app authority is unavailable before the exact creator thread is active. Retry this automation mutation from the current owner turn.",
);
})()
: undefined;
if (!canResolveScheduledConfiguredMcpCreatorAuthority) {
options?.signal?.throwIfAborted();
return Object.freeze({
tools: Object.freeze(authorityTools.map((entry) => Object.freeze(entry))),
provenance: Object.freeze(captureRef.value),
...(runtimeAuthority ? { runtimeAuthority } : {}),
});
}
const authorityRuntimeId = `cron-authority:${params.runId}`;
let materialized: Awaited<
@@ -423,10 +504,6 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) {
`${materialized.diagnosticNotice} Sign in to the affected MCP server and retry, or provide an explicit finite toolsAllow list containing only currently visible tools. No automation changes were saved.`,
);
}
const authorityTools: Array<string | { name: string; pluginId?: string }> = [];
const captureRef: {
value?: { version: 1; source: "final-executable-surface" };
} = {};
// Default authority contains model-callable tools only. App-only projections
// gate view callbacks and must never become headless scheduled capability.
const projectedConfiguredMcp = projectCodexExecutableDynamicTools({
@@ -444,6 +521,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) {
return Object.freeze({
tools: Object.freeze(authorityTools.map((entry) => Object.freeze(entry))),
provenance: Object.freeze(captureRef.value),
...(runtimeAuthority ? { runtimeAuthority } : {}),
});
} finally {
await materialized.dispose();
@@ -458,6 +536,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) {
configuredMcpOwnershipVersion: ownsScheduledConfiguredMcpSurface ? (1 as const) : undefined,
cronCreatorToolAllowlist,
cronCreatorToolAllowlistCaptureRef,
scheduledAppAuthoritySourceRef,
dynamicToolParams,
computerContextEpoch,
toolBridge,
@@ -313,6 +313,15 @@ export async function startCodexAttemptTurn(
await releaseSharedClientLeaseAndRetireOneShotClient();
throw new Error("codex app-server turn/start failed without an error");
}
const authoritySourceRef = context.attemptTools.scheduledAppAuthoritySourceRef;
if (resourceState.thread.pluginAppPolicyContext) {
authoritySourceRef.current = {
client: resourceState.client,
threadId: resourceState.thread.threadId,
policyContext: resourceState.thread.pluginAppPolicyContext,
configCwd: connection.effectiveCwd,
};
}
turnIdRef.current = turn.turn.id;
return { turn };
}
@@ -69,15 +69,18 @@ vi.mock("openclaw/plugin-sdk/codex-mcp-projection", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/codex-mcp-projection")>();
return {
...actual,
runWithCronCreatorAuthorityResolver: <T>(params: {
resolve: (options?: { signal?: AbortSignal }) => Promise<{
tools: readonly (string | { name: string; pluginId?: string })[];
provenance: { version: 1; source: "final-executable-surface" };
}>;
run: () => T;
}) => {
runWithCronCreatorAuthorityCapabilityResolver: (
params: Parameters<typeof actual.runWithCronCreatorAuthorityCapabilityResolver>[0],
) => {
if (
params.capability?.active !== true ||
!params.runId ||
params.capability.runId !== params.runId
) {
return actual.runWithCronCreatorAuthorityCapabilityResolver(params as never);
}
mcpMocks.authorityResolvers.push(params.resolve);
return params.run();
return actual.runWithCronCreatorAuthorityCapabilityResolver(params as never);
},
materializeStaticMcpToolsForScheduledHarnessRun: async (params: Record<string, unknown>) => {
mcpMocks.staticCalls.push(params);
@@ -202,6 +205,14 @@ function configureFakeMcp(params: ReturnType<typeof createParams>): void {
};
}
function admitLocalOperatorCronAuthority(params: ReturnType<typeof createParams>): void {
params.cronCreatorAuthorityCapability = {
active: true,
runId: params.runId,
signal: new AbortController().signal,
} as never;
}
describe("runCodexAppServerAttempt configured MCP ownership", () => {
it("does not replace bundle discovery with partial prepared plugin metadata", async () => {
const sessionFile = path.join(tempDir, "session-partial-manifest-registry.jsonl");
@@ -451,6 +462,38 @@ describe("runCodexAppServerAttempt configured MCP ownership", () => {
expect(mcpMocks.captureCalls[0]!.storedNames).not.toContain("fake__show");
});
it.each([
{ name: "missing", capabilityRunId: undefined },
{ name: "wrong-run", capabilityRunId: "other-run" },
])(
"does not bind $name local-operator authority at Codex tool construction",
async (testCase) => {
const sessionFile = path.join(tempDir, `session-local-operator-${testCase.name}.jsonl`);
const params = createParams(
sessionFile,
path.join(tempDir, `workspace-local-operator-${testCase.name}`),
);
configureFakeMcp(params);
params.trigger = "user";
params.senderIsOwner = false;
if (testCase.capabilityRunId) {
params.cronCreatorAuthorityCapability = {
active: true,
runId: testCase.capabilityRunId,
signal: new AbortController().signal,
} as never;
}
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await expect(run).resolves.toBeDefined();
expect(mcpMocks.authorityResolvers).toHaveLength(0);
},
);
it("lazily snapshots configured MCP through the local-operator resolver without replacing native MCP", async () => {
const sessionFile = path.join(tempDir, "session-local-operator-mutation.jsonl");
const params = createParams(
@@ -459,7 +502,8 @@ describe("runCodexAppServerAttempt configured MCP ownership", () => {
);
configureFakeMcp(params);
params.trigger = "user";
params.senderIsOwner = true;
params.senderIsOwner = false;
admitLocalOperatorCronAuthority(params);
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(params);
@@ -467,6 +511,7 @@ describe("runCodexAppServerAttempt configured MCP ownership", () => {
const threadStart = harness.requests.find((request) => request.method === "thread/start")
?.params as { config?: Record<string, unknown>; dynamicTools?: unknown } | undefined;
expect(JSON.stringify(threadStart?.config ?? {})).toContain("fake");
expect(JSON.stringify(threadStart?.dynamicTools ?? [])).toContain("automations");
expect(JSON.stringify(threadStart?.dynamicTools ?? [])).not.toContain("fake__show");
expect(mcpMocks.staticCalls).toHaveLength(0);
@@ -502,6 +547,7 @@ describe("runCodexAppServerAttempt configured MCP ownership", () => {
configureFakeMcp(params);
params.trigger = "user";
params.senderIsOwner = true;
admitLocalOperatorCronAuthority(params);
mcpMocks.staticDiagnosticNotice =
"Configured MCP is incomplete for this scheduled run: fake: authentication required.";
@@ -527,6 +573,7 @@ describe("runCodexAppServerAttempt configured MCP ownership", () => {
configureFakeMcp(params);
params.trigger = "user";
params.senderIsOwner = true;
admitLocalOperatorCronAuthority(params);
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(params);
@@ -558,6 +605,7 @@ describe("runCodexAppServerAttempt configured MCP ownership", () => {
configureFakeMcp(params);
params.trigger = "user";
params.senderIsOwner = true;
admitLocalOperatorCronAuthority(params);
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(params);
@@ -585,6 +633,7 @@ describe("runCodexAppServerAttempt configured MCP ownership", () => {
configureFakeMcp(params);
params.trigger = "user";
params.senderIsOwner = true;
admitLocalOperatorCronAuthority(params);
let releaseFailure!: () => void;
mcpMocks.staticFailureGate = new Promise<void>((resolve) => {
releaseFailure = resolve;
@@ -0,0 +1,571 @@
import { describe, expect, it, vi } from "vitest";
import {
createCodexPluginThreadConfigStartupProvider,
resolveCodexPluginThreadConfigStartupPolicy,
} from "./plugin-thread-config-deadline.js";
import {
buildPluginAppPolicyContext,
type CodexPluginThreadConfig,
} from "./plugin-thread-config.js";
import {
buildLegacyScheduledCodexAppRecoveryPrompt,
buildScheduledCodexAppAuthorityInputFingerprint,
captureScheduledCodexAppAuthority,
intersectCodexPluginThreadConfigWithScheduledAuthority,
resolveScheduledCodexAppCreatorCaptureDecision,
} from "./scheduled-app-authority.js";
function policyContext() {
return buildPluginAppPolicyContext(
{
calendar: {
source: "account",
appName: "Calendar",
allowDestructiveActions: true,
allowOpenWorld: true,
destructiveApprovalMode: "allow",
mcpServerNames: [],
},
disabled: {
source: "account",
appName: "Disabled",
allowDestructiveActions: false,
allowOpenWorld: false,
destructiveApprovalMode: "deny",
mcpServerNames: [],
},
},
{},
);
}
function authority(overrides?: Record<string, unknown>) {
return {
version: 1 as const,
runtimeId: "codex",
namespace: "codex.apps",
payload: {
version: 1,
auth: { profileId: "openai:work", accountId: "acct-1" },
apps: [
{
id: "calendar",
allowDestructiveActions: true,
allowOpenWorld: true,
destructiveApprovalMode: "allow",
tools: { list: "auto", edit: "approve" },
},
],
...overrides,
},
};
}
function threadConfig(): CodexPluginThreadConfig {
const context = buildPluginAppPolicyContext(
{
calendar: {
source: "account",
appName: "Calendar",
allowDestructiveActions: false,
allowOpenWorld: false,
destructiveApprovalMode: "ask",
mcpServerNames: [],
},
newly_connected: {
source: "account",
appName: "New",
allowDestructiveActions: true,
allowOpenWorld: true,
destructiveApprovalMode: "allow",
mcpServerNames: [],
},
},
{},
);
return {
enabled: true,
fingerprint: "current-fingerprint",
inputFingerprint: "current-input",
configPatch: { apps: {} },
provisionalAppIds: ["calendar", "newly_connected"],
policyContext: context,
diagnostics: [],
};
}
describe("scheduled Codex app authority", () => {
it.each([
{
name: "scheduled continuation",
overrides: { authenticatedScheduledMode: true },
message: "scheduled Codex continuation",
},
{
name: "supervision",
overrides: { usesSupervisionConnection: true },
message: "supervised connection",
},
{ name: "user home", overrides: { homeScope: "user" }, message: "user-home runtime" },
{
name: "missing account identity",
overrides: { hasPreparedAccountIdentity: false },
message: "genuine ChatGPT account identity",
},
])("refuses creator capture for $name before mutation", ({ overrides, message }) => {
const decision = resolveScheduledCodexAppCreatorCaptureDecision({
appsMayBeVisible: true,
authenticatedScheduledMode: false,
usesSupervisionConnection: false,
homeScope: "agent",
hasPreparedAccountIdentity: true,
...overrides,
});
expect(decision).toMatchObject({ required: true, supported: false });
expect(decision.unavailableReason).toContain(message);
expect(decision.unavailableReason).toContain("no automation changes were saved");
});
it("supports creator capture only with a prepared exact account identity", () => {
expect(
resolveScheduledCodexAppCreatorCaptureDecision({
appsMayBeVisible: true,
authenticatedScheduledMode: false,
usesSupervisionConnection: false,
homeScope: "agent",
hasPreparedAccountIdentity: true,
}),
).toEqual({ required: true, supported: true });
});
it("captures only connector-backed apps callable on the exact active thread", async () => {
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "app/installed") {
expect(params).toEqual({ threadId: "thread-final", forceRefresh: false });
return {
apps: [
{ id: "calendar", enabled: true, callable: true },
{ id: "disabled", enabled: false, callable: true },
],
};
}
if (method === "mcpServerStatus/list") {
expect(params).toMatchObject({ threadId: "thread-final", detail: "toolsAndAuthOnly" });
return {
data: [
{
name: "codex_apps",
tools: {
list: { _meta: { connector_id: "calendar" } },
create: { _meta: { connector_id: "calendar" } },
unrelated: { _meta: { connector_id: "other" } },
},
},
],
nextCursor: null,
};
}
if (method === "config/read") {
return {
config: { apps: { calendar: { tools: { list: { approval_mode: "writes" } } } } },
};
}
throw new Error(`unexpected method ${method}`);
});
const captured = await captureScheduledCodexAppAuthority({
client: { request } as never,
threadId: "thread-final",
policyContext: policyContext(),
profileId: "openai:work",
accountId: "acct-1",
configCwd: "/workspace",
});
expect(captured).toEqual(
authority({
apps: [
{
id: "calendar",
allowDestructiveActions: true,
allowOpenWorld: true,
destructiveApprovalMode: "allow",
tools: { create: "approve", list: "writes" },
},
],
}),
);
expect(request).toHaveBeenCalledWith(
"config/read",
{ includeLayers: false, cwd: "/workspace" },
expect.any(Object),
);
});
it("does not capture an installed app without exact-thread connector tools", async () => {
const request = vi.fn(async (method: string) => {
if (method === "app/installed") {
return { apps: [{ id: "calendar", enabled: true, callable: true }] };
}
if (method === "mcpServerStatus/list") {
return { data: [{ name: "codex_apps", tools: {} }], nextCursor: null };
}
return { config: {} };
});
await expect(
captureScheduledCodexAppAuthority({
client: { request } as never,
threadId: "thread-final",
policyContext: policyContext(),
profileId: "openai:work",
accountId: "acct-1",
}),
).resolves.toBeUndefined();
});
it("bounds creator capture when a later connector inventory page hangs", async () => {
let statusPage = 0;
const request = vi.fn(async (method: string) => {
if (method === "app/installed") {
return { apps: [{ id: "calendar", enabled: true, callable: true }] };
}
if (method === "config/read") {
return { config: {} };
}
if (method === "mcpServerStatus/list") {
statusPage += 1;
if (statusPage === 1) {
return { data: [], nextCursor: "page-2" };
}
return await new Promise<never>(() => {});
}
throw new Error(`unexpected method ${method}`);
});
const startedAt = Date.now();
await expect(
captureScheduledCodexAppAuthority({
client: { request } as never,
threadId: "thread-final",
policyContext: policyContext(),
profileId: "openai:work",
accountId: "acct-1",
timeoutMs: 100,
}),
).rejects.toThrow(
"Codex app authority capture exceeded its 100 ms total budget. No automation changes were saved",
);
expect(Date.now() - startedAt).toBeLessThan(1_000);
expect(statusPage).toBe(2);
});
it("maps a real app-server request timeout to the no-save creator diagnostic", async () => {
const timeout = Object.assign(new Error("mcpServerStatus/list timed out"), {
code: "CODEX_APP_SERVER_LOCAL_REQUEST_CANCELLED",
reason: "timed out",
mayHaveWritten: false,
});
const request = vi.fn(async (method: string) => {
if (method === "mcpServerStatus/list") {
throw timeout;
}
if (method === "app/installed") {
return { apps: [] };
}
return { config: {} };
});
await expect(
captureScheduledCodexAppAuthority({
client: { request } as never,
threadId: "thread-final",
policyContext: policyContext(),
profileId: "openai:work",
accountId: "acct-1",
}),
).rejects.toThrow("No automation changes were saved");
});
it("intersects stored and current app/tool authority without admitting new apps", () => {
const intersected = intersectCodexPluginThreadConfigWithScheduledAuthority(
threadConfig(),
authority(),
{
config: {
apps: {
calendar: {
tools: {
list: { approval_mode: "writes" },
edit: { approval_mode: "writes" },
newly_added: { approval_mode: "prompt" },
},
},
},
},
toolNamesByApp: new Map([["calendar", new Set(["list", "edit", "newly_added"])]]),
},
);
expect(intersected.provisionalAppIds).toEqual(["calendar"]);
expect(intersected.policyContext.apps).toEqual({
calendar: expect.objectContaining({
allowDestructiveActions: false,
allowOpenWorld: false,
destructiveApprovalMode: "ask",
}),
});
expect(intersected.configPatch).toMatchObject({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
calendar: {
enabled: true,
destructive_enabled: false,
open_world_enabled: false,
approvals_reviewer: "user",
tools: {
list: { approval_mode: "prompt" },
edit: { approval_mode: "prompt" },
newly_added: { approval_mode: "prompt" },
},
},
},
});
});
it("removes tools missing from current inventory and rotates the fingerprint", () => {
const full = intersectCodexPluginThreadConfigWithScheduledAuthority(
threadConfig(),
authority(),
{
config: {},
toolNamesByApp: new Map([["calendar", new Set(["list", "edit"])]]),
},
);
const narrowed = intersectCodexPluginThreadConfigWithScheduledAuthority(
threadConfig(),
authority(),
{
config: {},
toolNamesByApp: new Map([["calendar", new Set(["list"])]]),
},
);
expect(narrowed.configPatch).toMatchObject({
apps: { calendar: { tools: { list: expect.any(Object) } } },
});
const narrowedApps = narrowed.configPatch?.apps as
| Record<string, { tools?: unknown }>
| undefined;
expect(narrowedApps?.calendar?.tools).not.toHaveProperty("edit");
expect(narrowed.fingerprint).not.toBe(full.fingerprint);
});
it("fails before execution when a captured app has no current connector tools", () => {
expect(() =>
intersectCodexPluginThreadConfigWithScheduledAuthority(threadConfig(), authority(), {
config: {},
toolNamesByApp: new Map(),
}),
).toThrow("Scheduled Codex apps are unavailable under the current policy or account: calendar");
});
it.each([
{ mode: "allow" as const, expected: "approve" },
{ mode: "ask" as const, expected: "prompt" },
{ mode: "auto" as const, expected: "auto" },
])("maps an app-level $mode ceiling to headless tool mode $expected", ({ mode, expected }) => {
const context = buildPluginAppPolicyContext(
{
calendar: {
source: "account",
appName: "Calendar",
allowDestructiveActions: true,
allowOpenWorld: true,
destructiveApprovalMode: mode,
mcpServerNames: [],
},
},
{},
);
const config: CodexPluginThreadConfig = {
enabled: true,
fingerprint: "current",
inputFingerprint: "input",
policyContext: context,
diagnostics: [],
};
const intersected = intersectCodexPluginThreadConfigWithScheduledAuthority(
config,
authority(),
{
config: {},
toolNamesByApp: new Map([["calendar", new Set(["edit"])]]),
},
);
expect(intersected.configPatch).toMatchObject({
apps: { calendar: { tools: { edit: { approval_mode: expected } } } },
});
});
it("rotates the input fingerprint when the stored cap changes", () => {
const first = buildScheduledCodexAppAuthorityInputFingerprint("base", authority());
const second = buildScheduledCodexAppAuthorityInputFingerprint(
"base",
authority({
apps: [
{
id: "calendar",
allowDestructiveActions: false,
allowOpenWorld: false,
destructiveApprovalMode: "deny",
tools: {},
},
],
}),
);
expect(first).not.toBe("base");
expect(second).not.toBe(first);
expect(buildScheduledCodexAppAuthorityInputFingerprint("base", undefined)).toBe("base");
});
it("fails closed for unknown or malformed Codex authority", () => {
expect(() =>
intersectCodexPluginThreadConfigWithScheduledAuthority(threadConfig(), {
...authority(),
namespace: "codex.unknown",
}),
).toThrow(/Unsupported Codex scheduled authority namespace/);
expect(() =>
intersectCodexPluginThreadConfigWithScheduledAuthority(
threadConfig(),
authority({ apps: [{ id: "calendar" }] }),
),
).toThrow(/Stored Codex app authority is invalid/);
});
it("reports captured app ids omitted by current policy without exposing the envelope", () => {
const config = threadConfig();
delete config.policyContext.apps.calendar;
expect(() =>
intersectCodexPluginThreadConfigWithScheduledAuthority(config, authority()),
).toThrow(
"Scheduled Codex apps are unavailable under the current policy or account: calendar. Restore access or reauthorize the automation from a fresh authenticated Codex owner turn.",
);
});
it("gives legacy scheduled caps a bounded operator recovery instruction", () => {
const prompt = buildLegacyScheduledCodexAppRecoveryPrompt({
trigger: "cron",
scheduledRuntimeAuthorityRecoveryRequired: true,
});
expect(prompt).toContain("recreate or reauthorize");
expect(prompt).toContain("authenticated Codex owner turn");
expect(prompt).not.toContain("account-1");
expect(
buildLegacyScheduledCodexAppRecoveryPrompt({
trigger: "user",
scheduledRuntimeAuthorityRecoveryRequired: true,
}),
).toBeUndefined();
});
it("requires a deny-default app config for scheduled authority even when native tools are enabled", () => {
const startup = resolveCodexPluginThreadConfigStartupPolicy({
pluginConfig: {},
nativeToolSurfaceEnabled: true,
scheduledRuntimeAuthority: authority(),
});
expect(startup.pluginThreadConfigRequired).toBe(true);
expect(startup.resolvedPluginPolicy).toBeDefined();
});
it("bounds hanging current-policy inventory under the total startup deadline", async () => {
const request = vi.fn(async (method: string) => {
if (method === "config/read") {
return { config: {} };
}
if (method === "mcpServerStatus/list") {
return await new Promise<never>(() => {});
}
throw new Error(`unexpected method ${method}`);
});
const provider = createCodexPluginThreadConfigStartupProvider({
inputFingerprint: "scheduled-input",
enabledPluginConfigKeys: [],
policy: undefined,
requestTimeoutMs: 400,
signal: new AbortController().signal,
pluginConfig: {},
client: { request } as never,
appCacheKey: "account-1",
scheduledRuntimeAuthority: authority(),
});
const startedAt = Date.now();
await expect(provider.build()).rejects.toMatchObject({
name: "AgentHarnessPreflightError",
message: expect.stringContaining("Scheduled Codex app policy verification exceeded"),
});
expect(Date.now() - startedAt).toBeLessThan(1_000);
expect(request.mock.calls.map(([method]) => method)).toEqual([
"config/read",
"mcpServerStatus/list",
]);
});
it("reads current policy from the exact existing thread when supplied", async () => {
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "config/read") {
return { config: {} };
}
if (method === "mcpServerStatus/list") {
expect(params).toMatchObject({
threadId: "thread-existing",
detail: "toolsAndAuthOnly",
});
return {
data: [
{
name: "codex_apps",
tools: { list: { _meta: { connector_id: "calendar" } } },
},
],
nextCursor: null,
};
}
throw new Error(`unexpected method ${method}`);
});
const provider = createCodexPluginThreadConfigStartupProvider({
inputFingerprint: "scheduled-input",
enabledPluginConfigKeys: [],
policy: undefined,
requestTimeoutMs: 2_000,
signal: new AbortController().signal,
pluginConfig: {},
client: { request } as never,
appCacheKey: "account-1",
scheduledRuntimeAuthority: authority(),
});
await expect(provider.build({ threadId: "thread-existing" })).rejects.toThrow(
"Scheduled Codex apps are unavailable under the current policy or account: calendar",
);
expect(request).toHaveBeenCalledWith(
"mcpServerStatus/list",
expect.objectContaining({ threadId: "thread-existing" }),
expect.any(Object),
);
});
});
@@ -0,0 +1,602 @@
import crypto from "node:crypto";
import {
AgentHarnessPreflightError,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { isCodexAppServerRequestTimeoutError, type CodexAppServerClient } from "./client.js";
import type { CodexPluginDestructiveApprovalMode } from "./config.js";
import {
buildCodexPluginAppsConfigPatchFromPolicyContext,
buildPluginAppPolicyContext,
type CodexAppPolicyContextEntry,
type CodexPluginThreadConfig,
type PluginAppPolicyContext,
} from "./plugin-thread-config.js";
import { isJsonObject, type v2 } from "./protocol.js";
import type { CodexAttemptConnection } from "./run-attempt-connection.js";
import { withAbortableTimeout } from "./timeout.js";
const CODEX_SCHEDULED_APP_AUTHORITY_NAMESPACE = "codex.apps";
const CODEX_APPS_MCP_SERVER = "codex_apps";
const MCP_STATUS_PAGE_SIZE = 100;
const MCP_STATUS_MAX_PAGES = 100;
const CODEX_APP_AUTHORITY_CAPTURE_TIMEOUT_MS = 60_000;
const CODEX_APP_AUTHORITY_CAPTURE_MIN_TIMEOUT_MS = 100;
type CronRuntimeAuthority = NonNullable<EmbeddedRunAttemptParams["scheduledRuntimeAuthority"]>;
type CodexAppToolApprovalMode = "auto" | "prompt" | "writes" | "approve";
export type CurrentCodexScheduledAppPolicy = {
config: Record<string, unknown>;
toolNamesByApp: ReadonlyMap<string, ReadonlySet<string>>;
};
export function resolveScheduledCodexAppCreatorCaptureDecision(params: {
appsMayBeVisible: boolean;
authenticatedScheduledMode: boolean;
usesSupervisionConnection: boolean;
homeScope: string | undefined;
hasPreparedAccountIdentity: boolean;
}): { required: boolean; supported: boolean; unavailableReason?: string } {
if (!params.appsMayBeVisible) {
return { required: false, supported: false };
}
const unavailableReason = params.authenticatedScheduledMode
? "A scheduled Codex continuation cannot create new app-authorized automations. Recreate it from a fresh authenticated owner turn; no automation changes were saved."
: params.usesSupervisionConnection
? "Codex apps are visible through a supervised connection that cannot capture creator authority. Use an isolated prepared-profile Codex creator turn; no automation changes were saved."
: params.homeScope === "user"
? "Codex apps are visible through a user-home runtime that cannot capture isolated creator authority. Use an agent-scoped prepared-profile Codex creator turn; no automation changes were saved."
: !params.hasPreparedAccountIdentity
? "Codex app authority requires a genuine ChatGPT account identity. Reauthenticate the selected Codex profile, then retry; no automation changes were saved."
: undefined;
return {
required: true,
supported: !unavailableReason,
...(unavailableReason ? { unavailableReason } : {}),
};
}
type ScheduledCodexAppAuthorityPayload = {
version: 1;
auth: { profileId: string; accountId: string };
apps: Array<{
id: string;
allowDestructiveActions: boolean;
allowOpenWorld: boolean;
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
tools: Record<string, CodexAppToolApprovalMode>;
}>;
};
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readConnectorId(tool: unknown): string | undefined {
const meta = asRecord(asRecord(tool)?.["_meta"]);
return readString(meta?.connector_id) ?? readString(meta?.connectorId);
}
function normalizeApprovalMode(value: unknown): CodexPluginDestructiveApprovalMode | undefined {
return value === "allow" || value === "deny" || value === "auto" || value === "ask"
? value
: undefined;
}
function normalizeAppToolApprovalMode(value: unknown): CodexAppToolApprovalMode | undefined {
return value === "auto" || value === "prompt" || value === "writes" || value === "approve"
? value
: undefined;
}
function defaultApprovalMode(entry: CodexAppPolicyContextEntry) {
return entry.destructiveApprovalMode ?? (entry.allowDestructiveActions ? "allow" : "deny");
}
function parseScheduledCodexAppAuthority(
authority: EmbeddedRunAttemptParams["scheduledRuntimeAuthority"],
): ScheduledCodexAppAuthorityPayload | undefined {
if (!authority || authority.runtimeId !== "codex") {
return undefined;
}
if (authority.version !== 1) {
throw new Error("Unsupported Codex scheduled authority version; reauthorize this automation.");
}
if (authority.namespace !== CODEX_SCHEDULED_APP_AUTHORITY_NAMESPACE) {
throw new Error(
`Unsupported Codex scheduled authority namespace ${authority.namespace}; reauthorize this automation.`,
);
}
const payload = asRecord(authority.payload);
const auth = asRecord(payload?.auth);
const profileId = readString(auth?.profileId);
const accountId = readString(auth?.accountId);
if (payload?.version !== 1 || !profileId || !accountId || !Array.isArray(payload.apps)) {
throw new Error("Stored Codex app authority is invalid; reauthorize this automation.");
}
const seen = new Set<string>();
const apps = payload.apps.map((raw) => {
const app = asRecord(raw);
const id = readString(app?.id);
const destructiveApprovalMode = normalizeApprovalMode(app?.destructiveApprovalMode);
const rawTools = asRecord(app?.tools);
if (
!id ||
seen.has(id) ||
typeof app?.allowDestructiveActions !== "boolean" ||
typeof app.allowOpenWorld !== "boolean" ||
!destructiveApprovalMode ||
!rawTools
) {
throw new Error("Stored Codex app authority is invalid; reauthorize this automation.");
}
seen.add(id);
const tools: Record<string, CodexAppToolApprovalMode> = {};
for (const [name, rawMode] of Object.entries(rawTools)) {
const toolName = readString(name);
const mode = normalizeAppToolApprovalMode(rawMode);
if (!toolName || !mode) {
throw new Error("Stored Codex app authority is invalid; reauthorize this automation.");
}
tools[toolName] = mode;
}
return {
id,
allowDestructiveActions: app.allowDestructiveActions,
allowOpenWorld: app.allowOpenWorld,
destructiveApprovalMode,
tools,
};
});
return { version: 1, auth: { profileId, accountId }, apps };
}
type CodexScheduledAppPolicyRequest = (
method: string,
params: Record<string, unknown>,
) => Promise<unknown>;
async function readCodexScheduledAppToolNamesByApp(params: {
request: CodexScheduledAppPolicyRequest;
threadId?: string;
}): Promise<Map<string, Set<string>>> {
const toolNamesByApp = new Map<string, Set<string>>();
const seenCursors = new Set<string>();
let cursor: string | null | undefined;
for (let page = 0; page < MCP_STATUS_MAX_PAGES; page += 1) {
const response = await params.request("mcpServerStatus/list", {
...(params.threadId ? { threadId: params.threadId } : {}),
detail: "toolsAndAuthOnly",
limit: MCP_STATUS_PAGE_SIZE,
...(cursor ? { cursor } : {}),
});
if (!isJsonObject(response) || !Array.isArray(response.data)) {
throw new Error("Codex mcpServerStatus/list returned invalid scheduled app inventory");
}
for (const status of response.data) {
if (!isJsonObject(status) || !isJsonObject(status.tools)) {
throw new Error("Codex scheduled app inventory contained an invalid server status");
}
if (status.name !== CODEX_APPS_MCP_SERVER) {
continue;
}
for (const [toolName, tool] of Object.entries(status.tools)) {
const connectorId = readConnectorId(tool);
if (connectorId) {
const names = toolNamesByApp.get(connectorId) ?? new Set<string>();
names.add(toolName);
toolNamesByApp.set(connectorId, names);
}
}
}
if (
response.nextCursor !== undefined &&
response.nextCursor !== null &&
typeof response.nextCursor !== "string"
) {
throw new Error("Codex scheduled app inventory returned an invalid pagination cursor");
}
cursor = response.nextCursor;
if (!cursor) {
return toolNamesByApp;
}
if (seenCursors.has(cursor)) {
throw new Error("Codex app connector inventory repeated its pagination cursor");
}
seenCursors.add(cursor);
}
throw new Error("Codex app connector inventory exceeded its bounded page limit");
}
/** Reads the current account policy and connector-backed tool names under one caller deadline. */
export async function readCurrentCodexScheduledAppPolicy(params: {
request: CodexScheduledAppPolicyRequest;
configCwd?: string;
threadId?: string;
}): Promise<CurrentCodexScheduledAppPolicy> {
const [configResponse, toolNamesByApp] = await Promise.all([
params.request("config/read", {
includeLayers: false,
...(params.configCwd ? { cwd: params.configCwd } : {}),
}),
readCodexScheduledAppToolNamesByApp(params),
]);
if (!isJsonObject(configResponse)) {
throw new Error("Codex config/read returned an invalid scheduled app policy response");
}
return {
config: isJsonObject(configResponse.config) ? configResponse.config : {},
toolNamesByApp,
};
}
function readToolApprovalMode(
config: Record<string, unknown>,
appId: string,
toolName: string,
fallback: CodexAppToolApprovalMode = "auto",
): CodexAppToolApprovalMode {
const app = asRecord(asRecord(config.apps)?.[appId]);
const tool = asRecord(asRecord(app?.tools)?.[toolName]);
return normalizeAppToolApprovalMode(tool?.approval_mode) ?? fallback;
}
/** Captures only apps callable on the exact active Codex client/thread. */
export async function captureScheduledCodexAppAuthority(params: {
client: Pick<CodexAppServerClient, "request">;
threadId: string;
policyContext: PluginAppPolicyContext;
profileId: string;
accountId: string;
configCwd?: string;
signal?: AbortSignal;
timeoutMs?: number;
}): Promise<CronRuntimeAuthority | undefined> {
const requestedTimeoutMs = params.timeoutMs ?? CODEX_APP_AUTHORITY_CAPTURE_TIMEOUT_MS;
const timeoutMs = Math.min(
CODEX_APP_AUTHORITY_CAPTURE_TIMEOUT_MS,
Math.max(
CODEX_APP_AUTHORITY_CAPTURE_MIN_TIMEOUT_MS,
Number.isFinite(requestedTimeoutMs)
? Math.floor(requestedTimeoutMs)
: CODEX_APP_AUTHORITY_CAPTURE_TIMEOUT_MS,
),
);
const deadlineMs = Date.now() + timeoutMs;
const boundedClient = {
request: ((method: string, requestParams: unknown) => {
const remainingTimeoutMs = deadlineMs - Date.now();
if (remainingTimeoutMs <= 0) {
throw new CodexScheduledAppAuthorityCaptureTimeoutError();
}
return params.client.request(method as never, requestParams as never, {
timeoutMs: remainingTimeoutMs,
signal: params.signal,
});
}) as CodexAppServerClient["request"],
};
let installed: v2.AppsInstalledResponse;
let currentPolicy: CurrentCodexScheduledAppPolicy;
try {
[installed, currentPolicy] = await withAbortableTimeout({
promise: Promise.all([
boundedClient.request("app/installed", {
threadId: params.threadId,
forceRefresh: false,
}),
readCurrentCodexScheduledAppPolicy({
request: (method, requestParams) =>
boundedClient.request(method as never, requestParams as never),
threadId: params.threadId,
configCwd: params.configCwd,
}),
]),
timeoutMs,
signal: params.signal,
timeoutMessage: "Codex scheduled app authority capture deadline elapsed",
createTimeoutError: () => new CodexScheduledAppAuthorityCaptureTimeoutError(),
});
} catch (error) {
if (
params.signal?.aborted ||
(!(error instanceof CodexScheduledAppAuthorityCaptureTimeoutError) &&
!isCodexAppServerRequestTimeoutError(error))
) {
throw error;
}
throw new Error(
`Codex app authority capture exceeded its ${timeoutMs} ms total budget. No automation changes were saved; retry after Codex app inventory is responsive.`,
{ cause: error },
);
}
const callableIds = new Set(
installed.apps.filter((app) => app.enabled && app.callable).map((app) => app.id),
);
const apps = Object.entries(params.policyContext.apps)
.filter(([id]) => callableIds.has(id) && currentPolicy.toolNamesByApp.has(id))
.map(([id, policy]) => ({
id,
allowDestructiveActions: policy.allowDestructiveActions,
allowOpenWorld: policy.allowOpenWorld !== false,
destructiveApprovalMode: defaultApprovalMode(policy),
tools: Object.fromEntries(
[...(currentPolicy.toolNamesByApp.get(id) ?? [])]
.toSorted()
.map((toolName) => [
toolName,
readToolApprovalMode(
currentPolicy.config,
id,
toolName,
appApprovalCeiling(defaultApprovalMode(policy)),
),
]),
),
}))
.toSorted((left, right) => left.id.localeCompare(right.id));
if (apps.length === 0) {
return undefined;
}
return {
version: 1,
runtimeId: "codex",
namespace: CODEX_SCHEDULED_APP_AUTHORITY_NAMESPACE,
payload: {
version: 1,
auth: { profileId: params.profileId, accountId: params.accountId },
apps,
},
};
}
class CodexScheduledAppAuthorityCaptureTimeoutError extends Error {
constructor() {
super("Codex scheduled app authority capture deadline elapsed");
this.name = "CodexScheduledAppAuthorityCaptureTimeoutError";
}
}
const APPROVAL_RANK: Record<CodexPluginDestructiveApprovalMode, number> = {
deny: 0,
ask: 1,
auto: 2,
allow: 3,
};
function stricterApprovalMode(
left: CodexPluginDestructiveApprovalMode,
right: CodexPluginDestructiveApprovalMode,
): CodexPluginDestructiveApprovalMode {
return APPROVAL_RANK[left] <= APPROVAL_RANK[right] ? left : right;
}
function intersectToolApprovalMode(
captured: CodexAppToolApprovalMode,
current: CodexAppToolApprovalMode,
): CodexAppToolApprovalMode {
if (captured === current) {
return captured;
}
if (captured === "prompt" || current === "prompt") {
return "prompt";
}
if (captured === "approve") {
return current;
}
if (current === "approve") {
return captured;
}
// `auto` and `writes` are annotation-dependent and not totally ordered.
return "prompt";
}
function appApprovalCeiling(mode: CodexPluginDestructiveApprovalMode): CodexAppToolApprovalMode {
if (mode === "allow") {
return "approve";
}
return mode === "ask" ? "prompt" : "auto";
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.entries(value)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
/** Intersects a stored app-ID cap with current policy without admitting new apps. */
export function intersectCodexPluginThreadConfigWithScheduledAuthority(
config: CodexPluginThreadConfig,
authority: EmbeddedRunAttemptParams["scheduledRuntimeAuthority"],
currentPolicy: CurrentCodexScheduledAppPolicy = {
config: {},
toolNamesByApp: new Map(),
},
): CodexPluginThreadConfig {
const scheduled = parseScheduledCodexAppAuthority(authority);
if (!scheduled) {
return config;
}
const omittedAppIds = scheduled.apps
.map((app) => app.id)
.filter((id) => {
const currentTools = currentPolicy.toolNamesByApp.get(id);
return (
!Object.hasOwn(config.policyContext.apps, id) || !currentTools || currentTools.size === 0
);
})
.toSorted();
if (omittedAppIds.length > 0) {
const visibleIds = omittedAppIds.slice(0, 10).join(", ");
const remaining = omittedAppIds.length - Math.min(omittedAppIds.length, 10);
throw new AgentHarnessPreflightError(
`Scheduled Codex apps are unavailable under the current policy or account: ${visibleIds}${remaining > 0 ? ` (and ${remaining} more)` : ""}. Restore access or reauthorize the automation from a fresh authenticated Codex owner turn.`,
);
}
const capturedById = new Map(scheduled.apps.map((app) => [app.id, app] as const));
const apps: Record<string, CodexAppPolicyContextEntry> = {};
for (const [id, current] of Object.entries(config.policyContext.apps)) {
const captured = capturedById.get(id);
if (!captured) {
continue;
}
apps[id] = {
...current,
allowDestructiveActions: current.allowDestructiveActions && captured.allowDestructiveActions,
allowOpenWorld: current.allowOpenWorld !== false && captured.allowOpenWorld,
destructiveApprovalMode: stricterApprovalMode(
defaultApprovalMode(current),
captured.destructiveApprovalMode,
),
};
}
const pluginAppIds = Object.fromEntries(
Object.entries(config.policyContext.pluginAppIds)
.map(([key, ids]) => [key, ids.filter((id) => Object.hasOwn(apps, id))] as const)
.filter(([, ids]) => ids.length > 0),
);
const policyContext = buildPluginAppPolicyContext(apps, pluginAppIds);
const configPatch = buildCodexPluginAppsConfigPatchFromPolicyContext(policyContext);
const appsPatch = asRecord(configPatch.apps);
for (const [appId, captured] of capturedById) {
const appPatch = asRecord(appsPatch?.[appId]);
if (!appPatch || !Object.hasOwn(apps, appId)) {
continue;
}
const currentApp = apps[appId];
if (!currentApp) {
continue;
}
const storedAppCeiling = appApprovalCeiling(captured.destructiveApprovalMode);
const currentAppCeiling = appApprovalCeiling(defaultApprovalMode(currentApp));
// Current inventory owns existence; captured modes only cap tools that
// still exist (and tools added later within the already-authorized app).
const toolNames = currentPolicy.toolNamesByApp.get(appId) ?? new Set<string>();
appPatch.tools = Object.fromEntries(
[...toolNames].toSorted().map((toolName) => {
const capturedMode = captured.tools[toolName] ?? storedAppCeiling;
return [
toolName,
{
approval_mode: intersectToolApprovalMode(
intersectToolApprovalMode(capturedMode, storedAppCeiling),
intersectToolApprovalMode(
readToolApprovalMode(currentPolicy.config, appId, toolName, currentAppCeiling),
currentAppCeiling,
),
),
},
];
}),
);
}
const fingerprint = crypto
.createHash("sha256")
.update(
stableStringify({
version: 1,
namespace: CODEX_SCHEDULED_APP_AUTHORITY_NAMESPACE,
authority: scheduled,
inputFingerprint: config.inputFingerprint,
policyContext,
configPatch,
}),
)
.digest("hex");
return {
...config,
fingerprint,
configPatch,
provisionalAppIds: Object.keys(apps).toSorted(),
policyContext,
};
}
function readScheduledCodexAppAuthorityAuth(
authority: EmbeddedRunAttemptParams["scheduledRuntimeAuthority"],
): ScheduledCodexAppAuthorityPayload["auth"] | undefined {
return parseScheduledCodexAppAuthority(authority)?.auth;
}
export function assertScheduledCodexAppAuthorityRuntime(
connection: Pick<
CodexAttemptConnection,
"usesSupervisionConnection" | "appServer" | "startupPreparedAuth"
>,
params: Pick<EmbeddedRunAttemptParams, "trigger" | "scheduledRuntimeAuthority">,
): void {
const scheduledAuth = readScheduledCodexAppAuthorityAuth(params.scheduledRuntimeAuthority);
if (!scheduledAuth) {
return;
}
if (
params.trigger !== "cron" ||
connection.usesSupervisionConnection ||
connection.appServer.start.homeScope === "user"
) {
throw new AgentHarnessPreflightError(
"This automation's Codex app authority requires an isolated scheduled prepared-profile runtime. Reauthorize it from a supported Codex creator turn.",
);
}
const prepared = connection.startupPreparedAuth;
if (
prepared?.kind !== "profile" ||
prepared.profileId !== scheduledAuth.profileId ||
prepared.snapshot?.loginParams.type !== "chatgptAuthTokens" ||
prepared.snapshot.chatgptAccountId !== scheduledAuth.accountId
) {
throw new AgentHarnessPreflightError(
`This automation was authorized for Codex profile ${scheduledAuth.profileId}, but that exact prepared account is not active. Restore the profile or reauthorize the automation from a fresh owner turn.`,
);
}
}
export function buildLegacyScheduledCodexAppRecoveryPrompt(
params: Pick<
EmbeddedRunAttemptParams,
"trigger" | "scheduledRuntimeAuthority" | "scheduledRuntimeAuthorityRecoveryRequired"
>,
): string | undefined {
if (
params.trigger !== "cron" ||
!params.scheduledRuntimeAuthorityRecoveryRequired ||
params.scheduledRuntimeAuthority
) {
return undefined;
}
return "Scheduled Codex app access is unavailable because this automation predates runtime-specific app authority capture. Tell the operator to recreate or reauthorize it from a fresh authenticated Codex owner turn; do not claim an app action succeeded.";
}
/** Makes stored-cap identity part of thread reuse admission, including cap removal. */
export function buildScheduledCodexAppAuthorityInputFingerprint(
baseFingerprint: string,
authority: EmbeddedRunAttemptParams["scheduledRuntimeAuthority"],
): string {
const scheduled = parseScheduledCodexAppAuthority(authority);
if (!scheduled) {
return baseFingerprint;
}
return crypto
.createHash("sha256")
.update(
stableStringify({
version: 1,
namespace: CODEX_SCHEDULED_APP_AUTHORITY_NAMESPACE,
baseFingerprint,
authority: scheduled,
}),
)
.digest("hex");
}
@@ -10,6 +10,7 @@ export function canResolveScheduledConfiguredMcpCreatorAuthority(params: {
usesSupervisionConnection: boolean;
preservesNativeModel: boolean;
senderIsOwner?: boolean;
hasFreshCreatorAuthority?: boolean;
senderId?: string | null;
inputProvenance?: unknown;
trustedInternalHandoff?: unknown;
@@ -25,7 +26,7 @@ export function canResolveScheduledConfiguredMcpCreatorAuthority(params: {
!isIncognitoSessionKey(params.sessionKey) &&
!params.usesSupervisionConnection &&
!params.preservesNativeModel &&
params.senderIsOwner === true &&
(params.senderIsOwner === true || params.hasFreshCreatorAuthority === true) &&
!params.senderId &&
params.inputProvenance === undefined &&
params.trustedInternalHandoff === undefined &&
@@ -425,6 +425,7 @@ describe("Codex app-server binding store", () => {
source: "account" as const,
appName: "ChatGPT Meetings",
allowDestructiveActions: true,
allowOpenWorld: false,
destructiveApprovalMode: "auto" as const,
mcpServerNames: [],
},
@@ -178,6 +178,7 @@ const accountAppPolicyEntrySchema = z
source: z.literal("account"),
appName: z.string(),
allowDestructiveActions: z.boolean(),
allowOpenWorld: z.boolean().optional(),
destructiveApprovalMode: destructiveApprovalModeSchema,
mcpServerNames: z.array(z.string()),
})
@@ -192,6 +193,7 @@ const pluginAppPolicyEntrySchema = z
]),
pluginName: z.string(),
allowDestructiveActions: z.boolean(),
allowOpenWorld: z.boolean().optional(),
destructiveApprovalMode: destructiveApprovalModeSchema,
mcpServerNames: z.array(z.string()),
})
@@ -1427,6 +1429,7 @@ function readPluginAppPolicyContext(
"appId" in entry ||
typeof entry.appName !== "string" ||
typeof entry.allowDestructiveActions !== "boolean" ||
(entry.allowOpenWorld !== undefined && typeof entry.allowOpenWorld !== "boolean") ||
destructiveApprovalMode === "invalid" ||
!mcpServerNamesValid
) {
@@ -1436,6 +1439,9 @@ function readPluginAppPolicyContext(
source: "account",
appName: entry.appName,
allowDestructiveActions: entry.allowDestructiveActions,
...(typeof entry.allowOpenWorld === "boolean"
? { allowOpenWorld: entry.allowOpenWorld }
: {}),
...(destructiveApprovalMode ? { destructiveApprovalMode } : {}),
mcpServerNames: entry.mcpServerNames as string[],
};
@@ -1449,6 +1455,7 @@ function readPluginAppPolicyContext(
entry.marketplaceName !== CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME) ||
typeof entry.pluginName !== "string" ||
typeof entry.allowDestructiveActions !== "boolean" ||
(entry.allowOpenWorld !== undefined && typeof entry.allowOpenWorld !== "boolean") ||
destructiveApprovalMode === "invalid" ||
!mcpServerNamesValid
) {
@@ -1459,6 +1466,9 @@ function readPluginAppPolicyContext(
marketplaceName: entry.marketplaceName,
pluginName: entry.pluginName,
allowDestructiveActions: entry.allowDestructiveActions,
...(typeof entry.allowOpenWorld === "boolean"
? { allowOpenWorld: entry.allowOpenWorld }
: {}),
...(destructiveApprovalMode ? { destructiveApprovalMode } : {}),
mcpServerNames: entry.mcpServerNames as string[],
};
@@ -91,6 +91,7 @@ type ThreadRequestContext = {
type ResumeThreadContext = ThreadRequestContext & {
binding: CodexAppServerThreadBinding;
clearCurrentBinding: (operation: string) => Promise<void>;
prebuiltPluginThreadConfig?: CodexPluginThreadConfig;
prebuiltFinalConfigPatch?: {
configPatch?: JsonObject;
nativeHookRelayGeneration?: string;
@@ -163,9 +164,10 @@ export async function resumeExistingCodexThread(
// Codex rebuilds effective config on thread/resume, so replay the app
// allowlist persisted at thread/start or plugin tools disappear after one turn.
const pluginAppsConfigPatch =
params.pluginThreadConfig?.enabled && resumeBinding.pluginAppPolicyContext
context.prebuiltPluginThreadConfig?.configPatch ??
(params.pluginThreadConfig?.enabled && resumeBinding.pluginAppPolicyContext
? buildCodexPluginAppsConfigPatchFromPolicyContext(resumeBinding.pluginAppPolicyContext)
: undefined;
: undefined);
const resumeConfig = applyCodexNativeSkillIsolation(
mergeCodexThreadConfigs(
params.config,
@@ -281,9 +283,13 @@ export async function resumeExistingCodexThread(
resumeBinding.connectionScope === "supervision"
? buildCodexAppServerConnectionFingerprint(params.appServer, params.params.agentDir)
: params.appServerRuntimeFingerprint,
pluginAppsFingerprint: resumeBinding.pluginAppsFingerprint,
pluginAppsInputFingerprint: resumeBinding.pluginAppsInputFingerprint,
pluginAppPolicyContext: resumeBinding.pluginAppPolicyContext,
pluginAppsFingerprint:
context.prebuiltPluginThreadConfig?.fingerprint ?? resumeBinding.pluginAppsFingerprint,
pluginAppsInputFingerprint:
context.prebuiltPluginThreadConfig?.inputFingerprint ??
resumeBinding.pluginAppsInputFingerprint,
pluginAppPolicyContext:
context.prebuiltPluginThreadConfig?.policyContext ?? resumeBinding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
} satisfies Partial<Omit<CodexAppServerThreadBinding, "threadId">>;
@@ -502,18 +502,23 @@ export async function startOrResumeThread(
});
if (
!pluginBindingStale &&
shouldRecheckRecoverablePluginBinding({
binding,
pluginThreadConfig: params.pluginThreadConfig,
})
(params.pluginThreadConfig?.requiresCurrentPolicyCheck ||
shouldRecheckRecoverablePluginBinding({
binding,
pluginThreadConfig: params.pluginThreadConfig,
}))
) {
try {
const bindingThreadId = binding.threadId;
prebuiltPluginThreadConfig = await lifecycleTiming.measure("plugin-config-recovery", () =>
params.pluginThreadConfig?.build(),
params.pluginThreadConfig?.build({ threadId: bindingThreadId }),
);
pluginBindingStale =
prebuiltPluginThreadConfig?.fingerprint !== binding.pluginAppsFingerprint;
} catch (error) {
if (params.pluginThreadConfig?.requiresCurrentPolicyCheck) {
throw error;
}
embeddedAgentLog.warn("codex app-server plugin app config recovery check failed", {
error,
threadId: binding.threadId,
@@ -654,6 +659,7 @@ export async function startOrResumeThread(
throwIfAborted,
clearCurrentBinding,
prebuiltFinalConfigPatch: warmReuse.prebuiltFinalConfigPatch,
prebuiltPluginThreadConfig,
});
if (resumed) {
return resumed;
@@ -34,11 +34,13 @@ type CodexThreadFinalConfigPatchResult = {
export type CodexPluginThreadConfigProvider = {
enabled: boolean;
/** Rebuild before reuse so live policy can narrow or revoke stored authority. */
requiresCurrentPolicyCheck?: boolean;
inputFingerprint?: string;
enabledPluginConfigKeys?: readonly string[];
recoverablePluginConfigKeys?: readonly string[];
accountAppRecoveryEnabled?: boolean;
build: () => Promise<CodexPluginThreadConfig>;
build: (options?: { threadId?: string }) => Promise<CodexPluginThreadConfig>;
};
export type CodexStartOrResumeThreadParams = {
@@ -14,11 +14,13 @@ import {
type CodexDynamicToolFunctionSpec,
} from "./protocol.js";
import {
createCodexAppServerBindingStore,
sessionBindingIdentity,
type CodexAppServerBindingStore,
type CodexAppServerPendingSupervisionBranch,
} from "./session-binding.js";
import {
createCodexTestBindingStateStore,
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
} from "./session-binding.test-helpers.js";
@@ -2174,6 +2176,46 @@ describe("Codex plugin binding recovery", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
it("rechecks scheduled current policy against the exact existing thread", async () => {
const sessionFile = path.join(tempDir, "session-current-policy.jsonl");
const workspaceDir = path.join(tempDir, "workspace-current-policy");
const params = createThreadLifecycleParams(sessionFile, workspaceDir);
const request = vi.fn(async (method: string) => {
if (method === "thread/start" || method === "thread/resume") {
return threadStartResult("thread-current-policy");
}
throw new Error(`unexpected method: ${method}`);
});
const build = vi.fn(async (_options?: { threadId?: string }) => ({
enabled: true,
configPatch: { apps: { _default: { enabled: false } } },
fingerprint: "plugin-config-current-policy",
inputFingerprint: "plugin-input-current-policy",
policyContext: { fingerprint: "plugin-policy-current", apps: {}, pluginAppIds: {} },
diagnostics: [],
}));
const common = {
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
pluginThreadConfig: {
enabled: true,
requiresCurrentPolicyCheck: true,
inputFingerprint: "plugin-input-current-policy",
build,
},
};
await startOrResumeThread(common);
await startOrResumeThread(common);
expect(build).toHaveBeenNthCalledWith(1);
expect(build).toHaveBeenNthCalledWith(2, { threadId: "thread-current-policy" });
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
it("rebuilds once when a settled negative binding still enables the plugin", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -2249,6 +2291,127 @@ describe("Codex plugin binding recovery", () => {
"thread/resume",
]);
});
it("rotates warm bindings across scheduled authority changes and resumes after store restart", async () => {
const sessionFile = path.join(tempDir, "session-authority.jsonl");
const workspaceDir = path.join(tempDir, "workspace-authority");
const params = createThreadLifecycleParams(sessionFile, workspaceDir);
const stateStore = createCodexTestBindingStateStore();
let bindingStore = createCodexAppServerBindingStore(stateStore);
let threadSequence = 0;
const threadStarts: Array<Record<string, unknown>> = [];
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
threadSequence += 1;
threadStarts.push(requestParams as Record<string, unknown>);
return threadStartResult(`thread-authority-${threadSequence}`);
}
if (method === "thread/resume") {
const threadId = (requestParams as { threadId?: string })?.threadId;
return threadStartResult(threadId ?? "thread-resumed");
}
if (method === "app/installed") {
return {
apps: [{ id: "calendar", runtimeName: "Calendar", enabled: true, callable: true }],
};
}
throw new Error(`unexpected method: ${method}`);
});
const provider = (inputFingerprint: string, destructive: boolean) => {
const base = createProvisionalPluginThreadConfigProvider("calendar");
return {
...base,
requiresCurrentPolicyCheck: true,
inputFingerprint,
build: vi.fn(async () => {
const config = await base.build();
const apps = config.configPatch?.apps as Record<string, Record<string, unknown>>;
return {
...config,
inputFingerprint,
fingerprint: `${inputFingerprint}:${destructive}`,
configPatch: {
...config.configPatch,
apps: {
...apps,
calendar: {
...apps.calendar,
destructive_enabled: destructive,
tools: {
edit: { approval_mode: destructive ? "approve" : "prompt" },
},
},
},
},
};
}),
};
};
const common = {
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
};
await startOrResumeThreadImpl({
...common,
bindingStore,
pluginThreadConfig: provider("unrestricted", true),
});
const revokedProvider = provider("unrestricted", true);
revokedProvider.build.mockRejectedValueOnce(new Error("calendar revoked by current policy"));
await expect(
startOrResumeThreadImpl({
...common,
bindingStore,
pluginThreadConfig: revokedProvider,
}),
).rejects.toThrow("calendar revoked by current policy");
await startOrResumeThreadImpl({
...common,
bindingStore,
pluginThreadConfig: provider("scheduled-cap-1", false),
});
await startOrResumeThreadImpl({
...common,
bindingStore,
pluginThreadConfig: provider("unrestricted", true),
});
bindingStore = createCodexAppServerBindingStore(stateStore);
await startOrResumeThreadImpl({
...common,
bindingStore,
pluginThreadConfig: provider("unrestricted", true),
});
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"app/installed",
"thread/start",
"app/installed",
"thread/start",
"app/installed",
"thread/resume",
]);
expect(threadStarts).toHaveLength(3);
expect(threadStarts[1]?.config).toMatchObject({
apps: { calendar: { destructive_enabled: false } },
});
expect(threadStarts[2]?.config).toMatchObject({
apps: { calendar: { destructive_enabled: true } },
});
const resumeCall = request.mock.calls.find(([method]) => method === "thread/resume");
expect((resumeCall?.[1] as { config?: unknown })?.config).toMatchObject({
apps: {
calendar: {
destructive_enabled: true,
tools: { edit: { approval_mode: "approve" } },
},
},
});
});
});
describe("Codex thread-effective app attestation", () => {
@@ -4,6 +4,12 @@
*/
import { withTimeout as withSharedTimeout } from "openclaw/plugin-sdk/security-runtime";
function resolveAbortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error("Codex app-server operation aborted", { cause: signal.reason });
}
/** Awaits a promise with a Codex-specific timeout error message. */
export async function withTimeout<T>(
promise: Promise<T>,
@@ -16,3 +22,38 @@ export async function withTimeout<T>(
...(createError ? { createError } : {}),
});
}
/** Bounds an operation by both its owner lifecycle and one total wall-clock budget. */
export async function withAbortableTimeout<T>(params: {
promise: Promise<T>;
timeoutMs: number;
signal?: AbortSignal;
timeoutMessage: string;
createTimeoutError?: () => Error;
}): Promise<T> {
const signal = params.signal;
if (signal?.aborted) {
throw resolveAbortError(signal);
}
let removeAbortListener: (() => void) | undefined;
const operation = signal
? Promise.race([
params.promise,
new Promise<never>((_, reject) => {
const onAbort = () => reject(resolveAbortError(signal));
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
}),
])
: params.promise;
try {
return await withTimeout(
operation,
params.timeoutMs,
params.timeoutMessage,
params.createTimeoutError,
);
} finally {
removeAbortListener?.();
}
}
@@ -44,8 +44,9 @@ import { runWithAgentRingZeroTools } from "./agent-tools.ring-zero-context.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import { resolveConversationCapabilityProfile } from "./conversation-capability-profile.js";
import {
runWithCronCreatorAuthority,
runWithCronCreatorAuthorityResolver,
createCronCreatorAuthorityCapability,
runWithCronCreatorAuthorityCapability,
runWithCronCreatorAuthorityCapabilityResolver,
} from "./cron-creator-authority-context.js";
import * as openClawPluginTools from "./openclaw-plugin-tools.js";
import { createOpenClawTools } from "./openclaw-tools.js";
@@ -310,30 +311,41 @@ describe("createOpenClawCodingTools", () => {
let retainedResolver: (() => Promise<unknown>) | undefined;
vi.mocked(createOpenClawTools).mockClear();
runWithCronCreatorAuthorityResolver({
const forgedTools = runWithCronCreatorAuthorityCapabilityResolver({
capability: undefined,
runId: "forged-run",
resolve,
run: () => createOpenClawCodingTools({ runId: "forged-run" }),
run: () => createOpenClawCodingTools({ runId: "forged-run", senderIsOwner: false }),
});
expect(toolNameList(forgedTools)).not.toContain("automations");
expect(
vi.mocked(createOpenClawTools).mock.lastCall?.[0]?.resolveCronCreatorToolAuthority,
).toBeUndefined();
const activeRun = runWithCronCreatorAuthority("admitted-run", async () => {
runWithCronCreatorAuthorityResolver({
const capability = createCronCreatorAuthorityCapability("admitted-run")!;
const activeRun = runWithCronCreatorAuthorityCapability(capability, async () => {
const wrongRunTools = runWithCronCreatorAuthorityCapabilityResolver({
capability,
runId: "other-run",
resolve,
run: () => createOpenClawCodingTools({ runId: "admitted-run" }),
run: () => createOpenClawCodingTools({ runId: "admitted-run", senderIsOwner: false }),
});
expect(toolNameList(wrongRunTools)).not.toContain("automations");
expect(
vi.mocked(createOpenClawTools).mock.lastCall?.[0]?.resolveCronCreatorToolAuthority,
).toBeUndefined();
runWithCronCreatorAuthorityResolver({
const admittedTools = runWithCronCreatorAuthorityCapabilityResolver({
capability,
runId: "admitted-run",
resolve,
run: () => createOpenClawCodingTools({ runId: "admitted-run" }),
run: () => createOpenClawCodingTools({ runId: "admitted-run", senderIsOwner: false }),
});
const admittedToolNames = toolNameList(admittedTools);
expect(admittedToolNames).toContain("automations");
expect(admittedToolNames).not.toContain("gateway");
expect(admittedToolNames).not.toContain("nodes");
expect(admittedToolNames).not.toContain("openclaw");
retainedResolver =
vi.mocked(createOpenClawTools).mock.lastCall?.[0]?.resolveCronCreatorToolAuthority;
expect(retainedResolver).toEqual(expect.any(Function));
@@ -348,9 +360,51 @@ describe("createOpenClawCodingTools", () => {
await expect(retainedResolver!()).rejects.toThrow(
"Configured MCP cron authority is no longer active for this run",
);
expect(
toolNameList(createOpenClawCodingTools({ runId: "admitted-run", senderIsOwner: false })),
).not.toContain("automations");
expect(resolve).toHaveBeenCalledTimes(1);
});
it("drops senderless Automations retention when exact authority aborts or errors", async () => {
const resolve = async () => ({
tools: ["read"],
provenance: { version: 1 as const, source: "final-executable-surface" as const },
});
const buildTools = (capability: ReturnType<typeof createCronCreatorAuthorityCapability>) =>
runWithCronCreatorAuthorityCapabilityResolver({
capability,
runId: "lifecycle-run",
resolve,
run: () => createOpenClawCodingTools({ runId: "lifecycle-run", senderIsOwner: false }),
});
const abortController = new AbortController();
const abortedCapability = createCronCreatorAuthorityCapability("lifecycle-run")!;
await runWithCronCreatorAuthorityCapability(
abortedCapability,
async () => {
expect(toolNameList(buildTools(abortedCapability))).toContain("automations");
abortController.abort(new Error("run cancelled"));
expect(toolNameList(buildTools(abortedCapability))).not.toContain("automations");
},
abortController.signal,
);
expect(abortedCapability.active).toBe(false);
const failedCapability = createCronCreatorAuthorityCapability("lifecycle-run")!;
await expect(
runWithCronCreatorAuthorityCapability(failedCapability, async () => {
expect(toolNameList(buildTools(failedCapability))).toContain("automations");
throw new Error("run failed");
}),
).rejects.toThrow("run failed");
expect(failedCapability.active).toBe(false);
expect(
toolNameList(createOpenClawCodingTools({ runId: "lifecycle-run", senderIsOwner: false })),
).not.toContain("automations");
});
it("re-wraps existing before_tool_call hooks once with the current context", async () => {
const beforeToolCall = vi.fn();
initializeGlobalHookRunner(
+10 -2
View File
@@ -99,6 +99,7 @@ import {
type ToolSearchCatalogRef,
type ToolSearchCatalogToolExecutor,
} from "./tool-search.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
import {
replaceWithEffectiveCronCreatorToolAllowlist,
type CronCreatorToolAllowlistEntry,
@@ -639,8 +640,15 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
},
recordToolPrepStage: options?.recordToolPrepStage,
});
const cronCreatorAuthorityResolver = bindActiveCronCreatorAuthorityResolver(options?.runId);
// A fresh exact-run capability authorizes only automation creation. Keep every
// other owner-only control-plane tool denied for senderless operator turns.
const ownerOnlyCoreToolDenylist =
options?.senderIsOwner === false ? [...GATEWAY_OWNER_ONLY_CORE_TOOLS] : [];
options?.senderIsOwner === false
? GATEWAY_OWNER_ONLY_CORE_TOOLS.filter(
(toolName) => toolName !== AUTOMATIONS_TOOL_NAME || !cronCreatorAuthorityResolver,
)
: [];
const ownerOnlyCoreToolPolicy =
ownerOnlyCoreToolDenylist.length > 0 ? { deny: ownerOnlyCoreToolDenylist } : undefined;
const pluginToolAllowlist = appendRuntimePluginToolGrant(
@@ -789,7 +797,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
pluginToolDenylist,
cronCreatorToolAllowlist,
cronCreatorToolAllowlistCaptureRef,
resolveCronCreatorToolAuthority: bindActiveCronCreatorAuthorityResolver(options?.runId),
resolveCronCreatorToolAuthority: cronCreatorAuthorityResolver,
cronCreatorAuthorityUnavailableReason: options?.cronCreatorAuthorityUnavailableReason,
currentChannelId: options?.currentChannelId,
currentChatType: options?.chatType,
+106 -29
View File
@@ -1,5 +1,6 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
createCronCreatorAuthorityRunScope,
mintCronCreatorAuthorityGrant,
@@ -12,16 +13,50 @@ import type {
} from "./tools/cron-tool.types.js";
type CronCreatorAuthorityResolver = NonNullable<CronToolOptions["resolveCreatorToolAuthority"]>;
type CronCreatorAuthorityMaterializer = (options?: {
signal?: AbortSignal;
}) => Promise<CronCreatorToolAuthorityMaterialization>;
type CronCreatorAuthorityResolverScope = {
resolve: (options?: { signal?: AbortSignal }) => Promise<CronCreatorToolAuthorityMaterialization>;
resolve: CronCreatorAuthorityMaterializer;
runId: string;
};
/** Opaque in-process capability minted only by an admitted exact run. */
export type CronCreatorAuthorityCapability = CronCreatorAuthorityRunScope;
export function createCronCreatorAuthorityCapability(
runId: string,
): CronCreatorAuthorityCapability | undefined {
const normalizedRunId = runId.trim();
return normalizedRunId ? createCronCreatorAuthorityRunScope(normalizedRunId) : undefined;
}
const activeCronCreatorAuthority = new AsyncLocalStorage<CronCreatorAuthorityRunScope>();
const activeCronCreatorAuthorityResolver =
new AsyncLocalStorage<CronCreatorAuthorityResolverScope>();
export function shouldAdmitFreshChannelOwnerCronAuthority(params: {
senderIsOwner: boolean;
messageProvider?: string;
senderId?: string;
isHeartbeat: boolean;
isRoomEvent: boolean;
inputProvenance?: unknown;
spawnedBy?: string;
suppressNextUserMessagePersistence?: boolean;
}): boolean {
return (
params.senderIsOwner &&
Boolean(params.messageProvider) &&
Boolean(normalizeOptionalString(params.senderId)) &&
!params.isHeartbeat &&
!params.isRoomEvent &&
params.inputProvenance === undefined &&
params.spawnedBy === undefined &&
params.suppressNextUserMessagePersistence !== true
);
}
/** Keeps fresh cron reauthorization within one admitted Gateway agent run. */
export function runWithCronCreatorAuthority<T>(
runId: string,
@@ -33,6 +68,15 @@ export function runWithCronCreatorAuthority<T>(
return run();
}
const scope = createCronCreatorAuthorityRunScope(normalizedRunId);
return runWithCronCreatorAuthorityCapability(scope, run, signal);
}
/** Owns one explicitly transported creator-authority capability until run settlement. */
export function runWithCronCreatorAuthorityCapability<T>(
scope: CronCreatorAuthorityCapability,
run: () => T,
signal?: AbortSignal,
): T {
const revoke = () => revokeCronCreatorAuthorityRunScope(scope);
signal?.addEventListener("abort", revoke, { once: true });
if (signal?.aborted) {
@@ -56,10 +100,64 @@ export function runWithCronCreatorAuthority<T>(
}
}
/** Combines an admitted capability with a late exact-thread tool-surface resolver. */
function bindCronCreatorAuthorityResolver(params: {
capability: CronCreatorAuthorityCapability | undefined;
runId: string | undefined;
resolve: CronCreatorAuthorityMaterializer;
}): CronCreatorAuthorityResolver | undefined {
const normalizedRunId = params.runId?.trim();
const authority = params.capability;
if (!normalizedRunId || authority?.active !== true || authority.runId !== normalizedRunId) {
return undefined;
}
return async (options) => {
// Tool callbacks can run after construction; retain the exact scope object
// and let its owner revoke it when the admitted run settles.
const operationSignal = options?.signal;
authority.signal.throwIfAborted();
operationSignal?.throwIfAborted();
const signal = operationSignal
? AbortSignal.any([authority.signal, operationSignal])
: authority.signal;
const snapshot = await params.resolve({ signal });
authority.signal.throwIfAborted();
operationSignal?.throwIfAborted();
if (!authority.active) {
authority.signal.throwIfAborted();
}
return Object.freeze({
tools: snapshot.tools,
provenance: snapshot.provenance,
grant: mintCronCreatorAuthorityGrant(authority, operationSignal, snapshot.runtimeAuthority),
});
};
}
/** Installs an explicitly transported capability only for synchronous tool construction. */
export function runWithCronCreatorAuthorityCapabilityResolver<T>(params: {
capability: CronCreatorAuthorityCapability | undefined;
runId: string | undefined;
resolve: CronCreatorAuthorityMaterializer;
run: () => T;
}): T {
const normalizedRunId = params.runId?.trim();
const authority = params.capability;
if (!normalizedRunId || authority?.active !== true || authority.runId !== normalizedRunId) {
return params.run();
}
return activeCronCreatorAuthority.run(authority, () =>
activeCronCreatorAuthorityResolver.run(
{ runId: normalizedRunId, resolve: params.resolve },
params.run,
),
);
}
/** Carries a bundled-Codex resolver through synchronous core tool construction. */
export function runWithCronCreatorAuthorityResolver<T>(params: {
runId: string;
resolve: (options?: { signal?: AbortSignal }) => Promise<CronCreatorToolAuthorityMaterialization>;
resolve: CronCreatorAuthorityMaterializer;
run: () => T;
}): T {
return activeCronCreatorAuthorityResolver.run(
@@ -75,33 +173,12 @@ export function bindActiveCronCreatorAuthorityResolver(
const authority = activeCronCreatorAuthority.getStore();
const resolver = activeCronCreatorAuthorityResolver.getStore();
const normalizedRunId = runId?.trim();
if (
!normalizedRunId ||
authority?.active !== true ||
authority.runId !== normalizedRunId ||
resolver?.runId !== normalizedRunId
) {
if (!normalizedRunId || resolver?.runId !== normalizedRunId) {
return undefined;
}
return async (options) => {
// Tool callbacks can run on async resources created outside the ALS scope,
// so retain the exact scope object and revoke it when the run settles.
const operationSignal = options?.signal;
authority.signal.throwIfAborted();
operationSignal?.throwIfAborted();
const signal = operationSignal
? AbortSignal.any([authority.signal, operationSignal])
: authority.signal;
const snapshot = await resolver.resolve({ signal });
authority.signal.throwIfAborted();
operationSignal?.throwIfAborted();
if (!authority.active) {
authority.signal.throwIfAborted();
}
return Object.freeze({
tools: snapshot.tools,
provenance: snapshot.provenance,
grant: mintCronCreatorAuthorityGrant(authority, operationSignal),
});
};
return bindCronCreatorAuthorityResolver({
capability: authority,
runId: normalizedRunId,
resolve: resolver.resolve,
});
}
@@ -16,6 +16,7 @@ import type { InboundEventKind } from "../../../channels/inbound-event/kind.js";
import type { SessionToolOverrides } from "../../../config/sessions/types.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { GroupToolPolicyConfig } from "../../../config/types.tools.js";
import type { CronRuntimeAuthority } from "../../../cron/runtime-authority.js";
import type { ImageContent } from "../../../llm/types.js";
import type { MediaFact } from "../../../media/media-facts.js";
import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js";
@@ -36,6 +37,7 @@ import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.ex
import type { BootstrapContextRunKind } from "../../bootstrap-mode.js";
import type { AgentStreamParams, ClientToolDefinition } from "../../command/shared-types.js";
import type { ConversationRecallContext } from "../../conversation-recall.types.js";
import type { CronCreatorAuthorityCapability } from "../../cron-creator-authority-context.js";
import type { BlockReplyPayload } from "../../embedded-agent-payloads.js";
import type {
BlockReplyChunking,
@@ -111,6 +113,10 @@ export type RunEmbeddedAgentParams = {
trigger?: EmbeddedRunTrigger;
/** Stable cron job identifier populated for cron-triggered runs. */
jobId?: string;
/** Store-private runtime authority forwarded only by the cron execution owner. */
scheduledRuntimeAuthority?: CronRuntimeAuthority;
/** A known runtime-specific authority envelope was explicitly cleared. */
scheduledRuntimeAuthorityRecoveryRequired?: boolean;
/** Relative workspace path that memory-triggered writes are allowed to append to. */
memoryFlushWritePath?: string;
/** Delivery target for topic/thread routing. */
@@ -270,6 +276,8 @@ export type RunEmbeddedAgentParams = {
trustedInternalHandoff?: TrustedSubagentCompletionHandoff;
/** Trusted server-stamped authority for an explicitly capped scheduled run. */
scheduledToolPolicy?: ScheduledToolPolicyContext;
/** Host-stamped exact-run capability for late Codex creator-authority capture. */
cronCreatorAuthorityCapability?: CronCreatorAuthorityCapability;
/** Ephemeral reason fresh local-operator cron authority cannot survive this queued turn. */
cronCreatorAuthorityUnavailableReason?: "queued-local-operator";
/** Seen bootstrap truncation warning signatures for this session (once mode dedupe). */
@@ -407,6 +407,7 @@ export async function dispatchEmbeddedRunAttempt(input: {
inputProvenance: params.inputProvenance,
trustedInternalHandoff: params.trustedInternalHandoff,
scheduledToolPolicy: params.scheduledToolPolicy,
cronCreatorAuthorityCapability: params.cronCreatorAuthorityCapability,
cronCreatorAuthorityUnavailableReason: params.cronCreatorAuthorityUnavailableReason,
streamParams: params.streamParams,
modelRun: params.modelRun,
@@ -420,6 +421,8 @@ export async function dispatchEmbeddedRunAttempt(input: {
bootstrapContextMode: params.bootstrapContextMode,
bootstrapContextRunKind: params.bootstrapContextRunKind,
jobId: params.jobId,
scheduledRuntimeAuthority: params.scheduledRuntimeAuthority,
scheduledRuntimeAuthorityRecoveryRequired: params.scheduledRuntimeAuthorityRecoveryRequired,
toolsAllow: params.toolsAllow,
...(params.systemAgentTool ? { systemAgentTool: params.systemAgentTool } : {}),
cleanupBundleMcpOnRunEnd: params.cleanupBundleMcpOnRunEnd,
+7 -1
View File
@@ -1,3 +1,4 @@
import type { CronRuntimeAuthority } from "../../cron/runtime-authority.js";
import type { CronCreatorAuthorityGrant } from "../../gateway/cron-creator-authority-grant.js";
// Cron tool type declarations shared with the cron tool implementation.
import type { DeliveryContext } from "../../utils/delivery-context.shared.js";
@@ -22,9 +23,14 @@ export type CronToolsAllowCaptureRef = {
export type CronCreatorToolAuthorityMaterialization = {
tools: readonly CronCreatorToolAllowlistEntry[];
provenance: CronToolsAllowCaptureProvenance;
/** Opaque runtime-owned authority captured with the same exact executable surface. */
runtimeAuthority?: CronRuntimeAuthority;
};
export type CronCreatorToolAuthoritySnapshot = CronCreatorToolAuthorityMaterialization & {
export type CronCreatorToolAuthoritySnapshot = Omit<
CronCreatorToolAuthorityMaterialization,
"runtimeAuthority"
> & {
/** Gateway-process one-shot proof consumed only at the matching cron write. */
grant: CronCreatorAuthorityGrant;
};
@@ -203,6 +203,7 @@ export async function runEmbeddedFallbackCandidate(params: {
lifecycleGeneration: params.getLifecycleGeneration(),
allowGatewaySubagentBinding: true,
trigger: turn.isHeartbeat ? "heartbeat" : "user",
cronCreatorAuthorityCapability: turn.opts?.cronCreatorAuthorityCapability,
cronCreatorAuthorityUnavailableReason:
turn.opts?.turnAdoptionLifecycle?.cronCreatorAuthorityUnavailable,
groupId: resolveGroupSessionKey(turn.sessionCtx)?.id,
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { shouldAdmitFreshChannelOwnerCronAuthority } from "../../agents/cron-creator-authority-context.js";
const BASE = {
senderIsOwner: true,
messageProvider: "telegram",
senderId: "owner-1",
isHeartbeat: false,
isRoomEvent: false,
};
describe("fresh channel owner cron authority admission", () => {
it.each(["telegram", "discord", "slack", "custom-channel"])(
"admits an authenticated direct owner turn from %s without channel-specific policy",
(messageProvider) => {
expect(shouldAdmitFreshChannelOwnerCronAuthority({ ...BASE, messageProvider })).toBe(true);
},
);
it.each([
{ name: "non-owner", overrides: { senderIsOwner: false } },
{ name: "missing provider", overrides: { messageProvider: undefined } },
{ name: "missing sender", overrides: { senderId: undefined } },
{ name: "heartbeat", overrides: { isHeartbeat: true } },
{ name: "room event", overrides: { isRoomEvent: true } },
{ name: "continuation provenance", overrides: { inputProvenance: { kind: "continuation" } } },
{ name: "spawned session", overrides: { spawnedBy: "agent:parent" } },
{ name: "replayed turn", overrides: { suppressNextUserMessagePersistence: true } },
])("rejects $name", ({ overrides }) => {
expect(shouldAdmitFreshChannelOwnerCronAuthority({ ...BASE, ...overrides })).toBe(false);
});
});
+83 -40
View File
@@ -1,8 +1,14 @@
import crypto from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
hasLegacyAutoFallbackWithoutOrigin,
hasSessionAutoModelFallbackProvenance,
} from "../../agents/agent-scope.js";
import {
createCronCreatorAuthorityCapability,
runWithCronCreatorAuthorityCapability,
shouldAdmitFreshChannelOwnerCronAuthority,
} from "../../agents/cron-creator-authority-context.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js";
import { resolveOwnerPromptNumbers } from "../../agents/owner-display.js";
@@ -446,45 +452,82 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
? { ...sessionCtx.ReplyThreading, implicitCurrentMessage: "deny" as const }
: undefined;
return runReplyAgent({
commandBody: prefixedCommandBody,
transcriptCommandBody,
followupRun,
queueKey,
resolvedQueue,
shouldSteer,
shouldFollowup,
queueAdmissionState,
isActive,
isRunActive: () => {
const latestSessionState = resolvePreparedSessionState();
const latestActiveSessionId =
resolveActiveEmbeddedSessionId(latestSessionState.sessionFile) ??
latestSessionState.sessionId;
return embeddedAgentRuntime?.isEmbeddedAgentRunActive(latestActiveSessionId) ?? false;
},
opts,
typing,
sessionEntry: preparedSessionState.sessionEntry,
sessionStore,
sessionKey,
runtimePolicySessionKey,
storePath,
defaultModel,
agentCfgContextTokens: agentCfg?.contextTokens,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
toolProgressDetail:
normalizeToolProgressDetail(agentCfg?.toolProgressDetail) ??
normalizeToolProgressDetail(cfg.agents?.defaults?.toolProgressDetail),
isNewSession: params.isNewSession,
blockStreamingEnabled,
blockReplyChunking,
resolvedBlockStreamingBreak,
sessionCtx,
shouldInjectGroupIntro,
typingMode,
resetTriggered: effectiveResetTriggered,
replyThreadingOverride,
replyOperation: providedReplyOperation,
const admitFreshChannelOwnerCronAuthority = shouldAdmitFreshChannelOwnerCronAuthority({
senderIsOwner: command.senderIsOwner,
messageProvider,
senderId: sessionCtx.SenderId,
isHeartbeat,
isRoomEvent,
inputProvenance,
spawnedBy: preparedSessionState.sessionEntry?.spawnedBy,
suppressNextUserMessagePersistence: opts?.suppressNextUserMessagePersistence,
});
const authorityRunId = admitFreshChannelOwnerCronAuthority
? (opts?.runId ?? crypto.randomUUID())
: undefined;
const inheritedCronCreatorAuthorityCapability = opts?.cronCreatorAuthorityCapability;
const createdCronCreatorAuthorityCapability =
!inheritedCronCreatorAuthorityCapability && authorityRunId
? createCronCreatorAuthorityCapability(authorityRunId)
: undefined;
const cronCreatorAuthorityCapability =
inheritedCronCreatorAuthorityCapability ?? createdCronCreatorAuthorityCapability;
const execute = () =>
runReplyAgent({
commandBody: prefixedCommandBody,
transcriptCommandBody,
followupRun,
queueKey,
resolvedQueue,
shouldSteer,
shouldFollowup,
queueAdmissionState,
isActive,
isRunActive: () => {
const latestSessionState = resolvePreparedSessionState();
const latestActiveSessionId =
resolveActiveEmbeddedSessionId(latestSessionState.sessionFile) ??
latestSessionState.sessionId;
return embeddedAgentRuntime?.isEmbeddedAgentRunActive(latestActiveSessionId) ?? false;
},
opts:
authorityRunId || cronCreatorAuthorityCapability
? {
...opts,
...(authorityRunId ? { runId: authorityRunId } : {}),
...(cronCreatorAuthorityCapability ? { cronCreatorAuthorityCapability } : {}),
}
: opts,
typing,
sessionEntry: preparedSessionState.sessionEntry,
sessionStore,
sessionKey,
runtimePolicySessionKey,
storePath,
defaultModel,
agentCfgContextTokens: agentCfg?.contextTokens,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
toolProgressDetail:
normalizeToolProgressDetail(agentCfg?.toolProgressDetail) ??
normalizeToolProgressDetail(cfg.agents?.defaults?.toolProgressDetail),
isNewSession: params.isNewSession,
blockStreamingEnabled,
blockReplyChunking,
resolvedBlockStreamingBreak,
sessionCtx,
shouldInjectGroupIntro,
typingMode,
resetTriggered: effectiveResetTriggered,
replyThreadingOverride,
replyOperation: providedReplyOperation,
});
// The scope surrounds the whole immediate turn, including provider fallbacks.
// If runReplyAgent queues this input, the scope settles before later drain/replay.
return createdCronCreatorAuthorityCapability
? runWithCronCreatorAuthorityCapability(
createdCronCreatorAuthorityCapability,
execute,
opts?.abortSignal,
)
: execute();
}
+3
View File
@@ -1,4 +1,5 @@
import type { QueueMode } from "../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { CronCreatorAuthorityCapability } from "../../agents/cron-creator-authority-context.js";
import type { SessionToolOverrides } from "../../config/sessions/types.js";
// Shared get-reply type contracts for command, directive, and runtime layers.
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -18,6 +19,8 @@ export type ReplySessionBinding = {
};
type InternalReplySessionOptions = {
/** Host-stamped exact-run capability for late Codex creator-authority capture. */
cronCreatorAuthorityCapability?: CronCreatorAuthorityCapability;
expectedExistingSessionId?: string;
onDeliberateSilentTerminalReply?: () => void;
onPendingContinuation?: () => void;
+29 -3
View File
@@ -13,6 +13,7 @@ import {
finalizeAcceptedContextEngineTurn,
type ContextEngineTurnAttemptFacts,
} from "../../agents/harness/context-engine-turn-attempt.js";
import { AgentHarnessPreflightError } from "../../agents/harness/errors.js";
import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js";
@@ -37,8 +38,9 @@ import {
getGeneratedMediaTaskIdsForSessionKey,
hasNewGeneratedMediaTaskForSessionKey,
} from "../../tasks/task-status-access.js";
import type { CronRuntimeAuthority } from "../runtime-authority.js";
import { resolveCronScheduledToolPolicy } from "../scheduled-tool-policy.js";
import type { CronAgentExecutionPhaseUpdate, CronJob } from "../types.js";
import type { CronAgentExecutionPhaseUpdate, CronJob, CronStoredJob } from "../types.js";
import {
resolveCronChannelOutputPolicy,
resolveCurrentChannelTarget,
@@ -72,6 +74,22 @@ import { resolveEffectiveAgentRuntime, resolveThinkingDefault } from "./run.runt
import { isLikelyInterimCronMessage } from "./subagent-followup-hints.js";
type AgentTurnPayload = Extract<CronJob["payload"], { kind: "agentTurn" }> | null;
function assertCronRuntimeAuthorityCandidate(params: {
authority?: CronRuntimeAuthority;
candidateRuntime: string;
cliExecution: boolean;
}): void {
const authority = params.authority;
if (!authority) {
return;
}
if (params.candidateRuntime !== authority.runtimeId || params.cliExecution) {
throw new AgentHarnessPreflightError(
`This automation carries ${authority.namespace} authority captured for the ${authority.runtimeId} runtime, but the selected execution runtime is ${params.candidateRuntime}. Restore that runtime and auth profile, or explicitly replace the automation's toolsAllow cap from an authenticated creator turn.`,
);
}
}
type CronPromptRunResult = Awaited<ReturnType<typeof runCliAgent>>;
type CronEmbeddedRuntime = typeof import("./run-embedded.runtime.js");
type CronSubagentRegistryRuntime = typeof import("./run-subagent-registry.runtime.js");
@@ -217,7 +235,7 @@ export type CronExecutionResult = {
function createCronPromptExecutor(params: {
cfg: OpenClawConfig;
cfgWithAgentDefaults: OpenClawConfig;
job: CronJob;
job: CronStoredJob;
agentId: string;
agentDir: string;
agentSessionKey: string;
@@ -497,6 +515,11 @@ function createCronPromptExecutor(params: {
modelId: modelOverride,
}) ?? providerOverride));
const cliExecution = isCliProvider(executionProvider, params.cfgWithAgentDefaults);
assertCronRuntimeAuthorityCandidate({
authority: params.job.runtimeAuthority,
candidateRuntime,
cliExecution,
});
await params.setRunContinuationCliExecutionProvider?.(
cliExecution ? executionProvider : undefined,
);
@@ -667,6 +690,9 @@ function createCronPromptExecutor(params: {
bootstrapContextMode,
bootstrapContextRunKind: "cron",
toolsAllow: params.agentPayload?.toolsAllow,
scheduledRuntimeAuthority: params.job.runtimeAuthority,
scheduledRuntimeAuthorityRecoveryRequired:
params.job.runtimeAuthorityRecoveryRequired === true,
scheduledToolPolicy,
execOverrides: params.suppressExecNotifyOnExit
? {
@@ -757,7 +783,7 @@ function createCronPromptExecutor(params: {
export async function executeCronRun(params: {
cfg: OpenClawConfig;
cfgWithAgentDefaults: OpenClawConfig;
job: CronJob;
job: CronStoredJob;
agentId: string;
agentDir: string;
agentSessionKey: string;
@@ -11,6 +11,7 @@ import {
resolveAgentConfigMock,
resolveConfiguredModelRefMock,
resolveCliRuntimeExecutionProviderMock,
resolveEffectiveAgentRuntimeMock,
resolveAgentModelFallbacksOverrideMock,
runCliAgentMock,
runEmbeddedAgentMock,
@@ -109,6 +110,49 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
expect(requireModelFallbackRequest().fallbacksOverride).toEqual(expectedFallbacks);
});
it("keeps pre-envelope app-less default caps free of recovery prompt changes", async () => {
mockRunCronFallbackPassthrough();
resolveEffectiveAgentRuntimeMock.mockReturnValue("codex");
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentParamsFixture({
job: makeIsolatedAgentJobFixture({
toolsAllowProvenance: { version: 1, source: "final-executable-surface" },
payload: {
kind: "agentTurn",
message: "use calendar",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
}),
}),
);
expect(result.status).toBe("ok");
expect(runEmbeddedAgentMock).toHaveBeenCalledWith(
expect.objectContaining({ scheduledRuntimeAuthorityRecoveryRequired: false }),
);
});
it("forwards reauthorization recovery after an explicit tools cap clears app authority", async () => {
mockRunCronFallbackPassthrough();
resolveEffectiveAgentRuntimeMock.mockReturnValue("codex");
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentParamsFixture({
job: makeIsolatedAgentJobFixture({
runtimeAuthorityRecoveryRequired: true,
payload: { kind: "agentTurn", message: "use calendar", toolsAllow: ["read"] },
}),
}),
);
expect(result.status).toBe("ok");
expect(runEmbeddedAgentMock).toHaveBeenCalledWith(
expect.objectContaining({ scheduledRuntimeAuthorityRecoveryRequired: true }),
);
});
it("classifies isolated cron results for model fallback", async () => {
const classification = { reason: "format", code: "empty_result" };
classifyEmbeddedAgentRunResultForModelFallbackMock.mockReturnValue(classification);
@@ -212,6 +256,33 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
expect(secondCliRequest?.suppressNextUserMessagePersistence).toBe(true);
});
it.each([
{ name: "a different embedded runtime", runtime: "openclaw", cli: false },
{ name: "a CLI execution path", runtime: "codex", cli: true },
])("fails closed before executing stored Codex authority on $name", async ({ runtime, cli }) => {
mockRunCronFallbackPassthrough();
resolveEffectiveAgentRuntimeMock.mockReturnValue(runtime);
isCliProviderMock.mockReturnValue(cli);
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentParamsFixture({
job: makeIsolatedAgentJobFixture({
runtimeAuthority: {
version: 1,
runtimeId: "codex",
namespace: "codex.apps",
payload: { version: 1 },
},
}),
}),
);
expect(result.status).toBe("error");
expect(result.error).toContain("authority captured for the codex runtime");
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(runCliAgentMock).not.toHaveBeenCalled();
});
it("forwards subagent fallbacks into the embedded runner for internal failover decisions", async () => {
mockRunCronFallbackPassthrough();
+2 -2
View File
@@ -29,7 +29,7 @@ import { getActiveCronTaskRunId } from "../service/task-runs.js";
import type {
CronAgentExecutionPhaseUpdate,
CronAgentExecutionStarted,
CronJob,
CronStoredJob,
} from "../types.js";
import { finalizeCronRun } from "./run-finalize.js";
import { prepareCronRunContext } from "./run-prepare.js";
@@ -80,7 +80,7 @@ async function disposeCronRunContext(params: {
export async function runCronIsolatedAgentTurn(params: {
cfg: OpenClawConfig;
deps: CliDeps;
job: CronJob;
job: CronStoredJob;
message: string;
abortSignal?: AbortSignal;
signal?: AbortSignal;
+15
View File
@@ -13,6 +13,7 @@ import { shouldDefaultCronDeliveryToAnnounce } from "./delivery-defaults.js";
import { parseDeliveryInput } from "./delivery-field-schemas.js";
import { normalizeCronCommandArgv, normalizeCronPayload } from "./normalize-payload.js";
import { parseAbsoluteTimeMs } from "./parse.js";
import { normalizeCronRuntimeAuthority } from "./runtime-authority.js";
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
import { normalizeCronScheduledToolPolicy } from "./scheduled-tool-policy.js";
import { inferCronJobName } from "./service/normalize.js";
@@ -411,6 +412,20 @@ export function normalizeCronJobInput(
}
}
if ("runtimeAuthority" in base) {
const runtimeAuthority = normalizeCronRuntimeAuthority(base.runtimeAuthority);
if (runtimeAuthority) {
next.runtimeAuthority = runtimeAuthority;
} else {
delete next.runtimeAuthority;
}
}
if (base.runtimeAuthorityRecoveryRequired === true) {
next.runtimeAuthorityRecoveryRequired = true;
} else {
delete next.runtimeAuthorityRecoveryRequired;
}
if ("agentId" in base) {
const agentId = base.agentId;
if (agentId === null) {
+19
View File
@@ -62,4 +62,23 @@ describe("toPublicCronJob", () => {
source: "final-executable-surface",
});
});
it("strips private runtime authority without mutating the stored job", () => {
const runtimeAuthority = {
version: 1 as const,
runtimeId: "codex",
namespace: "codex.apps",
payload: { apps: [{ id: "calendar" }] },
};
const job: CronStoredJob = {
...makeCronJob({}),
runtimeAuthority,
runtimeAuthorityRecoveryRequired: true,
};
expect(toPublicCronJob(job)).not.toHaveProperty("runtimeAuthority");
expect(toPublicCronJob(job)).not.toHaveProperty("runtimeAuthorityRecoveryRequired");
expect(job.runtimeAuthority).toEqual(runtimeAuthority);
expect(job.runtimeAuthorityRecoveryRequired).toBe(true);
});
});
+6 -1
View File
@@ -2,7 +2,12 @@ import type { CronJob, CronStoredJob } from "./types.js";
/** Remove scheduler-only state before a cron job crosses a public API boundary. */
export function toPublicCronJob(job: CronStoredJob): CronJob {
const { toolsAllowProvenance: _toolsAllowProvenance, ...publicJob } = job;
const {
toolsAllowProvenance: _toolsAllowProvenance,
runtimeAuthority: _runtimeAuthority,
runtimeAuthorityRecoveryRequired: _runtimeAuthorityRecoveryRequired,
...publicJob
} = job;
const state = { ...job.state };
delete state.queuedAtMs;
delete state.startupCatchupAtMs;
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { normalizeCronRuntimeAuthority } from "./runtime-authority.js";
function authority(payload: unknown) {
return {
version: 1,
runtimeId: "codex",
namespace: "codex.apps",
payload,
};
}
describe("normalizeCronRuntimeAuthority", () => {
it("normalizes and deeply freezes a bounded JSON authority envelope", () => {
const input = authority({ apps: [{ id: "calendar", enabled: true }] });
const normalized = normalizeCronRuntimeAuthority(input);
if (!normalized) {
throw new Error("expected normalized runtime authority");
}
expect(normalized).toEqual(input);
expect(normalized).not.toBe(input);
expect(Object.isFrozen(normalized)).toBe(true);
expect(Object.isFrozen(normalized.payload)).toBe(true);
expect(Object.isFrozen((normalized.payload.apps as unknown[])[0])).toBe(true);
});
it.each([
authority({ value: Number.NaN }),
authority({ value: Number.POSITIVE_INFINITY }),
authority({ value: undefined }),
authority({ value: 1n }),
authority({ value: new Date() }),
{ ...authority({}), extra: true },
{ ...authority({}), runtimeId: "Codex" },
{ ...authority({}), namespace: "codex apps" },
])("rejects non-JSON or non-canonical envelopes", (input) => {
expect(normalizeCronRuntimeAuthority(input)).toBeUndefined();
});
it("rejects cyclic and excessively deep payloads", () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
let deep: Record<string, unknown> = {};
for (let index = 0; index < 18; index += 1) {
deep = { child: deep };
}
expect(normalizeCronRuntimeAuthority(authority(cyclic))).toBeUndefined();
expect(normalizeCronRuntimeAuthority(authority(deep))).toBeUndefined();
});
it("preserves hostile JSON keys as inert data", () => {
const payload = JSON.parse('{"__proto__":{"polluted":true}}') as Record<string, unknown>;
const normalized = normalizeCronRuntimeAuthority(authority(payload));
expect(Object.getPrototypeOf(normalized?.payload)).toBeNull();
expect(Object.getOwnPropertyDescriptor(normalized?.payload, "__proto__")?.value).toEqual({
polluted: true,
});
expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
});
it("rejects the complete envelope above 64 KiB", () => {
expect(
normalizeCronRuntimeAuthority(authority({ data: "x".repeat(64 * 1024) })),
).toBeUndefined();
});
});
+136
View File
@@ -0,0 +1,136 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { isRecord } from "../utils.js";
const CRON_RUNTIME_AUTHORITY_MAX_BYTES = 64 * 1024;
const CRON_RUNTIME_AUTHORITY_MAX_ID_LENGTH = 128;
const CRON_RUNTIME_AUTHORITY_MAX_DEPTH = 16;
const CRON_RUNTIME_AUTHORITY_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u;
const CRON_RUNTIME_AUTHORITY_KEYS = new Set(["version", "runtimeId", "namespace", "payload"]);
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export type CronRuntimeAuthority = Readonly<{
version: 1;
/** Concrete harness runtime that alone may consume this opaque authority. */
runtimeId: string;
/** Runtime-owned payload discriminator; core never interprets its value. */
namespace: string;
payload: Readonly<Record<string, unknown>>;
}>;
function normalizeAuthorityId(value: unknown): string | undefined {
const normalized = normalizeOptionalString(value);
return normalized &&
normalized.length <= CRON_RUNTIME_AUTHORITY_MAX_ID_LENGTH &&
CRON_RUNTIME_AUTHORITY_ID_PATTERN.test(normalized)
? normalized
: undefined;
}
function cloneJsonValue(
value: unknown,
seen: WeakSet<object>,
depth: number,
): JsonValue | undefined {
if (depth > CRON_RUNTIME_AUTHORITY_MAX_DEPTH) {
return undefined;
}
if (value === null || typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (typeof value !== "object") {
return undefined;
}
if (seen.has(value)) {
return undefined;
}
seen.add(value);
if (Array.isArray(value)) {
const result: JsonValue[] = [];
for (const item of value) {
const cloned = cloneJsonValue(item, seen, depth + 1);
if (cloned === undefined) {
return undefined;
}
result.push(cloned);
}
seen.delete(value);
return result;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
return undefined;
}
// A null prototype preserves hostile-but-valid JSON keys such as `__proto__`
// as data instead of invoking an object-literal prototype setter.
const result: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>;
for (const [key, item] of Object.entries(value)) {
const cloned = cloneJsonValue(item, seen, depth + 1);
if (cloned === undefined) {
return undefined;
}
result[key] = cloned;
}
seen.delete(value);
return result;
}
function cloneJsonObject(value: unknown): Record<string, JsonValue> | undefined {
if (!isRecord(value) || Array.isArray(value)) {
return undefined;
}
const cloned = cloneJsonValue(value, new WeakSet(), 0);
return isRecord(cloned) && !Array.isArray(cloned)
? (cloned as Record<string, JsonValue>)
: undefined;
}
function deepFreezeJson(value: JsonValue): JsonValue {
if (value && typeof value === "object") {
for (const item of Array.isArray(value) ? value : Object.values(value)) {
deepFreezeJson(item);
}
Object.freeze(value);
}
return value;
}
/** Validates the private persisted transport without learning runtime-owned payload semantics. */
export function normalizeCronRuntimeAuthority(value: unknown): CronRuntimeAuthority | undefined {
if (
!isRecord(value) ||
value.version !== 1 ||
Object.keys(value).some((key) => !CRON_RUNTIME_AUTHORITY_KEYS.has(key)) ||
!Object.hasOwn(value, "runtimeId") ||
!Object.hasOwn(value, "namespace") ||
!Object.hasOwn(value, "payload")
) {
return undefined;
}
const runtimeId = normalizeAuthorityId(value.runtimeId);
const namespace = normalizeAuthorityId(value.namespace);
const payload = cloneJsonObject(value.payload);
if (!runtimeId || !namespace || !payload) {
return undefined;
}
const normalized = {
version: 1,
runtimeId,
namespace,
payload: deepFreezeJson(payload) as Readonly<Record<string, unknown>>,
} as const;
if (Buffer.byteLength(JSON.stringify(normalized), "utf8") > CRON_RUNTIME_AUTHORITY_MAX_BYTES) {
return undefined;
}
return Object.freeze(normalized);
}
export function cloneCronRuntimeAuthority(
value: CronRuntimeAuthority,
): CronRuntimeAuthority | undefined {
return normalizeCronRuntimeAuthority(value);
}
+66 -4
View File
@@ -12,11 +12,13 @@ import {
noteActiveCronJobTriggerMutation,
onCronJobInactive,
} from "../active-jobs.js";
import { cloneCronRuntimeAuthority, type CronRuntimeAuthority } from "../runtime-authority.js";
import { cronSchedulingInputsEqual } from "../schedule-identity.js";
import { removeCronJobBaseSession } from "../session-reaper.js";
import { removeStaleCronJobFamilyRows } from "../store.js";
import { createCronStreamSourceIdentity, cronStreamScheduleKey } from "../stream-schedule.js";
import { normalizeCronTaskRunJobId } from "../task-run-history.js";
import { cronJobUsesToolRuntime } from "../tools-allow.js";
import type { CronJob, CronJobCreate, CronJobPatch, CronStoredJob } from "../types.js";
import {
computeJobNextRunAtMs,
@@ -217,12 +219,54 @@ function declarativeFields(job: CronStoredJob, includeEnabled: boolean) {
payload: job.payload,
scheduledToolPolicy: job.scheduledToolPolicy,
toolsAllowProvenance: job.toolsAllowProvenance,
runtimeAuthority: job.runtimeAuthority,
runtimeAuthorityRecoveryRequired: job.runtimeAuthorityRecoveryRequired,
delivery: job.delivery,
displayName: job.displayName,
...(includeEnabled ? { enabled: job.enabled } : {}),
};
}
function reconcileRuntimeAuthority(params: {
job: CronStoredJob;
captured: boolean;
runtimeAuthority?: CronRuntimeAuthority;
explicitlyMutatesToolsAllow: boolean;
}): void {
if (!cronJobUsesToolRuntime(params.job)) {
// Runtime authority cannot survive a payload transition into a path that
// does not execute the captured tool surface and later reappear on reuse.
delete params.job.runtimeAuthority;
delete params.job.runtimeAuthorityRecoveryRequired;
return;
}
if (params.captured) {
delete params.job.runtimeAuthorityRecoveryRequired;
const runtimeAuthority = params.runtimeAuthority
? cloneCronRuntimeAuthority(params.runtimeAuthority)
: undefined;
if (params.runtimeAuthority && !runtimeAuthority) {
throw new TypeError("captured cron runtime authority is invalid");
}
if (runtimeAuthority) {
params.job.runtimeAuthority = runtimeAuthority;
} else {
// A fresh exact-surface capture with no runtime authority intentionally
// replaces any older runtime-specific grant instead of retaining it.
delete params.job.runtimeAuthority;
}
return;
}
if (params.explicitlyMutatesToolsAllow) {
// Explicit tool caps are a complete replacement. Runtime-owned authority
// may be restored only by another authenticated exact-surface capture.
if (params.job.runtimeAuthority) {
params.job.runtimeAuthorityRecoveryRequired = true;
delete params.job.runtimeAuthority;
}
}
}
/** Adds or converges a declaration-keyed cron job inside one store lock and write transaction. */
export async function add(
state: CronServiceState,
@@ -286,6 +330,13 @@ export async function add(
toolsAllowProvenance: opts?.toolsAllowProvenance,
configuredChannels,
});
const capturedRuntimeAuthority = opts?.commitGuard?.();
reconcileRuntimeAuthority({
job: nextJob,
captured: opts?.commitGuard !== undefined,
runtimeAuthority: capturedRuntimeAuthority,
explicitlyMutatesToolsAllow: normalizedInput.payload.toolsAllow !== undefined,
});
const includeEnabled = opts?.enabledExplicit === true;
if (
isDeepStrictEqual(
@@ -293,7 +344,6 @@ export async function add(
declarativeFields(nextJob, includeEnabled),
)
) {
opts?.commitGuard?.();
return { ...existing, created: false, updated: false, job: existing };
}
const snapshot = snapshotStoreForRollback(state);
@@ -304,7 +354,6 @@ export async function add(
schedulingInputsRequested: true,
scheduleChanged: !isDeepStrictEqual(existing.schedule, nextJob.schedule),
});
opts?.commitGuard?.();
await persistUpdatedJob({ state, snapshot, previousJob: existing, nextJob });
return { ...nextJob, created: false, updated: true, job: nextJob };
}
@@ -318,7 +367,13 @@ export async function add(
toolsAllowProvenance: opts?.toolsAllowProvenance,
configuredChannels,
});
opts?.commitGuard?.();
const capturedRuntimeAuthority = opts?.commitGuard?.();
reconcileRuntimeAuthority({
job,
captured: opts?.commitGuard !== undefined,
runtimeAuthority: capturedRuntimeAuthority,
explicitlyMutatesToolsAllow: normalizedInput.payload.toolsAllow !== undefined,
});
state.store?.jobs.push(job);
// Mutation notifications describe durable state, so publish them only
@@ -429,7 +484,14 @@ export async function updateLoadedJob(params: {
"pacing" in patch,
scheduleChanged: patch.schedule !== undefined,
});
opts?.commitGuard?.();
const capturedRuntimeAuthority = opts?.commitGuard?.();
reconcileRuntimeAuthority({
job: nextJob,
captured: opts?.commitGuard !== undefined,
runtimeAuthority: capturedRuntimeAuthority,
explicitlyMutatesToolsAllow:
patch.payload !== undefined && Object.hasOwn(patch.payload, "toolsAllow"),
});
const snapshot = snapshotStoreForRollback(state);
await persistUpdatedJob({ state, snapshot, previousJob: job, nextJob });
return nextJob;
+70
View File
@@ -107,6 +107,7 @@ describe("scheduled tool policy provenance", () => {
});
const commitGuard = vi.fn(() => {
expect(state.store?.jobs[0]?.name).toBe("original");
return undefined;
});
await expect(
@@ -186,6 +187,73 @@ describe("scheduled tool policy provenance", () => {
}
});
it("stamps, preserves, replaces, and clears private runtime authority at mutation ownership", async () => {
const { storePath } = await makeStorePath();
const state = createOkIsolatedCronState({
storePath,
now: Date.now(),
triggersEnabled: true,
});
const baseAuthority = {
version: 1 as const,
runtimeId: "codex",
namespace: "codex.apps",
payload: { apps: [{ id: "calendar" }] },
};
const job = await add(
state,
{
name: "runtime-capped",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "run", toolsAllow: ["*"] },
},
{ commitGuard: () => baseAuthority },
);
expect(job.runtimeAuthority).toEqual(baseAuthority);
const routine = await update(state, job.id, { description: "preserve" });
expect(routine.runtimeAuthority).toEqual(baseAuthority);
const explicit = await update(state, job.id, {
payload: { kind: "agentTurn", toolsAllow: ["read"] },
});
expect(explicit.runtimeAuthority).toBeUndefined();
expect(explicit.runtimeAuthorityRecoveryRequired).toBe(true);
const replacement = { ...baseAuthority, payload: { apps: [{ id: "mail" }] } };
const replaced = await update(
state,
job.id,
{ description: "recaptured" },
{ commitGuard: () => replacement },
);
expect(replaced.runtimeAuthority).toEqual(replacement);
expect(replaced.runtimeAuthorityRecoveryRequired).toBeUndefined();
const triggeredTransport = await add(
state,
{
name: "trigger-capped",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
trigger: { script: "return true" },
payload: { kind: "command", argv: ["true"] },
},
{ commitGuard: () => baseAuthority },
);
expect(triggeredTransport.runtimeAuthority).toEqual(baseAuthority);
const nonToolRuntime = await update(state, triggeredTransport.id, { trigger: null });
expect(nonToolRuntime.runtimeAuthority).toBeUndefined();
if (state.timer) {
clearTimeout(state.timer);
}
});
it("stamps trusted and authenticated-account creates", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-07-23T12:00:00.000Z");
@@ -306,6 +374,7 @@ function createOkIsolatedCronState(params: {
now: number;
summary?: string;
onEvent?: (event: CronEvent) => void;
triggersEnabled?: boolean;
}) {
return createCronServiceState({
storePath: params.storePath,
@@ -314,6 +383,7 @@ function createOkIsolatedCronState(params: {
nowMs: () => params.now,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
...(params.triggersEnabled ? { cronConfig: { triggers: { enabled: true } } } : {}),
runIsolatedAgentJob: vi.fn(async () => ({
status: "ok" as const,
...(params.summary === undefined ? {} : { summary: params.summary }),
+3 -2
View File
@@ -7,6 +7,7 @@ import type { CommandLaneTaskMarker } from "../../process/command-queue.js";
import { LEGACY_IMPLICIT_AGENT_ID } from "../../routing/session-key.js";
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { CronActiveJobMarker } from "../active-jobs.js";
import type { CronRuntimeAuthority } from "../runtime-authority.js";
import type { CronScheduledToolPolicy } from "../scheduled-tool-policy.js";
import type { QuarantinedCronConfigJob } from "../store.js";
import type {
@@ -392,7 +393,7 @@ export type CronAddOptions = {
/** Private proof from an authenticated agent-runtime caller. */
toolsAllowProvenance?: CronToolsAllowProvenance;
/** Synchronous Gateway-owned guard consumed immediately before mutation. */
commitGuard?: () => void;
commitGuard?: () => CronRuntimeAuthority | undefined;
};
/** Normalized patch input accepted by cron service updates. */
export type CronUpdateInput = CronJobPatch;
@@ -401,7 +402,7 @@ export type CronUpdateOptions = {
scheduledToolPolicy?: CronScheduledToolPolicy;
toolsAllowProvenance?: CronToolsAllowProvenance;
/** Synchronous Gateway-owned guard consumed immediately before mutation. */
commitGuard?: () => void;
commitGuard?: () => CronRuntimeAuthority | undefined;
};
export type CronCommitGuardOptions = {
+22
View File
@@ -55,6 +55,28 @@ describe("schedule column codec round-trip", () => {
});
});
it("round-trips private runtime authority and drops malformed envelopes", () => {
const runtimeAuthority = {
version: 1 as const,
runtimeId: "codex",
namespace: "codex.apps",
payload: { apps: [{ id: "calendar" }] },
};
const job = projectCronJobThroughStorageCodec({
...makeCronJob({}),
runtimeAuthority,
runtimeAuthorityRecoveryRequired: true,
});
expect(job.runtimeAuthority).toEqual(runtimeAuthority);
expect(job.runtimeAuthorityRecoveryRequired).toBe(true);
const malformed = projectCronJobThroughStorageCodec({
...makeCronJob({}),
runtimeAuthority: { ...runtimeAuthority, version: 2 } as never,
});
expect(malformed.runtimeAuthority).toBeUndefined();
});
it("round-trips pacing through the additive job_json envelope", () => {
const job = projectCronJobThroughStorageCodec(
makeCronJob({ pacing: { min: "15m", max: "4h" } }),
+6
View File
@@ -7,6 +7,7 @@ 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";
import { normalizeCronRuntimeAuthority } from "../runtime-authority.js";
import { tryCronScheduleIdentity } from "../schedule-identity.js";
import { normalizeCronScheduledToolPolicy } from "../scheduled-tool-policy.js";
import type {
@@ -291,6 +292,7 @@ function rowToCronJob(row: CronJobRow, jobJson: Record<string, unknown>): CronSt
jobJson.toolsAllowProvenance.source === "final-executable-surface"
? ({ version: 1, source: "final-executable-surface" } as const)
: undefined;
const runtimeAuthority = normalizeCronRuntimeAuthority(jobJson.runtimeAuthority);
if (!schedule || !payload) {
return null;
}
@@ -310,6 +312,10 @@ function rowToCronJob(row: CronJobRow, jobJson: Record<string, unknown>): CronSt
: {}),
...(scheduledToolPolicy ? { scheduledToolPolicy } : {}),
...(toolsAllowProvenance ? { toolsAllowProvenance } : {}),
...(runtimeAuthority ? { runtimeAuthority } : {}),
...(jobJson.runtimeAuthorityRecoveryRequired === true
? { runtimeAuthorityRecoveryRequired: true as const }
: {}),
name: row.name,
...(row.description ? { description: row.description } : {}),
enabled: row.enabled !== 0,
+6
View File
@@ -3,6 +3,7 @@ import type { EmbeddedAgentExecutionPhase } from "../agents/embedded-agent-runne
import type { FailoverReason } from "../agents/failover/signal.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import type { HookExternalContentSource } from "../security/external-content.js";
import type { CronRuntimeAuthority } from "./runtime-authority.js";
import type { CronScheduledToolPolicy } from "./scheduled-tool-policy.js";
import type { CronJobBase, CronPacing } from "./types-shared.js";
@@ -495,6 +496,10 @@ export type CronToolsAllowProvenance = {
/** Persisted row shape; public Gateway and wire contracts use CronJob. */
export type CronStoredJob = CronJob & {
toolsAllowProvenance?: CronToolsAllowProvenance;
/** Runtime-private authority omitted from public Gateway and wire contracts. */
runtimeAuthority?: CronRuntimeAuthority;
/** Authority was explicitly cleared and must be reauthorized before app reuse. */
runtimeAuthorityRecoveryRequired?: true;
};
/** Versioned cron store file shape. */
@@ -532,6 +537,7 @@ export type CronJobPatch = Partial<
| "owner"
| "scheduledToolPolicy"
| "pacing"
| "trigger"
>
> & {
displayName?: string | null;
@@ -71,4 +71,23 @@ describe("cron creator authority grants", () => {
revokeCronCreatorAuthorityRunScope(revokedScope);
expect(revokedRemove).toHaveBeenCalledWith("abort", expect.any(Function));
});
it("transports a private immutable runtime authority only through one-shot consumption", () => {
const scope = createCronCreatorAuthorityRunScope("run-authority");
const runtimeAuthority = {
version: 1 as const,
runtimeId: "codex",
namespace: "codex.apps",
payload: { apps: [{ id: "calendar" }] },
};
const grant = mintCronCreatorAuthorityGrant(scope, undefined, runtimeAuthority);
expect(grant).toEqual({ runId: "run-authority", token: expect.any(String) });
expect(consumeCronCreatorAuthorityGrant(grant)).toEqual(runtimeAuthority);
expect(() => consumeCronCreatorAuthorityGrant(grant)).toThrow(
"Configured MCP cron authority is no longer active",
);
revokeCronCreatorAuthorityRunScope(scope);
});
});
+18 -2
View File
@@ -1,4 +1,5 @@
import { randomBytes } from "node:crypto";
import { cloneCronRuntimeAuthority, type CronRuntimeAuthority } from "../cron/runtime-authority.js";
export type CronCreatorAuthorityGrant = Readonly<{
runId: string;
@@ -15,6 +16,7 @@ export type CronCreatorAuthorityRunScope = {
type CronCreatorAuthorityGrantEntry = {
scope: CronCreatorAuthorityRunScope;
runtimeAuthority?: CronRuntimeAuthority;
operationSignal?: AbortSignal;
onOperationAbort?: () => void;
};
@@ -44,12 +46,23 @@ export function createCronCreatorAuthorityRunScope(runId: string): CronCreatorAu
export function mintCronCreatorAuthorityGrant(
scope: CronCreatorAuthorityRunScope,
operationSignal?: AbortSignal,
runtimeAuthority?: CronRuntimeAuthority,
): CronCreatorAuthorityGrant {
if (!scope.active || scope.signal.aborted || operationSignal?.aborted) {
throw expiredAuthorityError();
}
const token = randomBytes(32).toString("base64url");
const entry: CronCreatorAuthorityGrantEntry = { scope, operationSignal };
const normalizedRuntimeAuthority = runtimeAuthority
? cloneCronRuntimeAuthority(runtimeAuthority)
: undefined;
if (runtimeAuthority && !normalizedRuntimeAuthority) {
throw new TypeError("cron creator runtime authority is invalid");
}
const entry: CronCreatorAuthorityGrantEntry = {
scope,
operationSignal,
...(normalizedRuntimeAuthority ? { runtimeAuthority: normalizedRuntimeAuthority } : {}),
};
if (operationSignal) {
entry.onOperationAbort = () => revokeCronCreatorAuthorityGrant(token);
}
@@ -85,7 +98,9 @@ export function revokeCronCreatorAuthorityRunScope(scope: CronCreatorAuthorityRu
}
/** Consumes one live exact-run grant synchronously at the cron commit boundary. */
export function consumeCronCreatorAuthorityGrant(grant: CronCreatorAuthorityGrant): void {
export function consumeCronCreatorAuthorityGrant(
grant: CronCreatorAuthorityGrant,
): CronRuntimeAuthority | undefined {
const runId = grant.runId.trim();
const token = grant.token.trim();
const entry = token ? grantsByToken.get(token) : undefined;
@@ -105,4 +120,5 @@ export function consumeCronCreatorAuthorityGrant(grant: CronCreatorAuthorityGran
throw expiredAuthorityError();
}
revokeCronCreatorAuthorityGrant(token);
return entry.runtimeAuthority ? cloneCronRuntimeAuthority(entry.runtimeAuthority) : undefined;
}
@@ -1,3 +1,4 @@
import type { CronCreatorAuthorityCapability } from "../../agents/cron-creator-authority-context.js";
import type { InputProvenance } from "../../sessions/input-provenance.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
@@ -15,5 +16,5 @@ export type ChatSendExternalAuthorityAdmission = {
isSystemGenerated: boolean;
turnKind: "btw" | "main";
}): Readonly<{ runId: string }> | undefined;
run<T>(authority: Readonly<{ runId: string }>, run: () => T, signal?: AbortSignal): T;
run<T>(capability: CronCreatorAuthorityCapability, run: () => T, signal?: AbortSignal): T;
};
@@ -1,4 +1,4 @@
import { runWithCronCreatorAuthority } from "../../agents/cron-creator-authority-context.js";
import { runWithCronCreatorAuthorityCapability } from "../../agents/cron-creator-authority-context.js";
import { isIncognitoSessionKey } from "../../routing/session-key.js";
import type { ChatSendExternalAuthorityAdmission } from "./chat-send-external-authority-contract.js";
import { handleChatSend } from "./chat-send-handler.js";
@@ -21,7 +21,7 @@ const externalAuthorityAdmission: ChatSendExternalAuthorityAdmission = {
turnKind: params.turnKind,
isDirectExternalUser: true,
}),
run: (authority, run, signal) => runWithCronCreatorAuthority(authority.runId, run, signal),
run: (capability, run, signal) => runWithCronCreatorAuthorityCapability(capability, run, signal),
};
/** Authenticated external chat entry; internal re-entry must call handleChatSend directly. */
@@ -6,6 +6,7 @@ import {
} from "../../../packages/gateway-protocol/src/client-info.js";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { createCronCreatorAuthorityCapability } from "../../agents/cron-creator-authority-context.js";
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js";
import { createAgentRunRestartAbortError } from "../../agents/run-termination.js";
import { dispatchInboundMessageWithProjectedDispatcher } from "../../auto-reply/dispatch.js";
@@ -442,6 +443,9 @@ export async function handleChatSend(
}
}
applyChatSendManagedMedia(ctx, await pluginBoundMediaPromise);
const cronCreatorAuthorityCapability = cronCreatorAuthority
? createCronCreatorAuthorityCapability(cronCreatorAuthority.runId)
: undefined;
const dispatchInbound = () =>
dispatchInboundMessageWithProjectedDispatcher({
ctx,
@@ -454,6 +458,7 @@ export async function handleChatSend(
},
replyOptions: {
runId: clientRunId,
...(cronCreatorAuthorityCapability ? { cronCreatorAuthorityCapability } : {}),
...(isOperatorUiClient(clientInfo)
? {
promptCacheKey: resolveWebchatPromptCacheKey({
@@ -567,9 +572,10 @@ export async function handleChatSend(
},
},
});
const dispatchResult = await (cronCreatorAuthority && externalAuthorityAdmission
const dispatchResult = await (cronCreatorAuthorityCapability &&
externalAuthorityAdmission
? externalAuthorityAdmission.run(
cronCreatorAuthority,
cronCreatorAuthorityCapability,
dispatchInbound,
activeRunAbort.controller.signal,
)
@@ -17,7 +17,8 @@ import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-prot
import { createDeferred } from "../../../test/helpers/promise.js";
import {
bindActiveCronCreatorAuthorityResolver,
runWithCronCreatorAuthorityResolver,
runWithCronCreatorAuthorityCapabilityResolver,
type CronCreatorAuthorityCapability,
} from "../../agents/cron-creator-authority-context.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { onTrustedMessageAuditEvent } from "../../audit/message-audit-events.js";
@@ -164,7 +165,10 @@ const mockState = vi.hoisted(() => ({
runtimeAssistantContentBeforeDelivery: null as Array<Record<string, unknown>> | null,
runtimeAssistantTextsBeforeDelivery: [] as string[],
cronAuthorityProbe: undefined as
| ((runId: string | undefined) => Promise<void> | void)
| ((
runId: string | undefined,
capability: CronCreatorAuthorityCapability | undefined,
) => Promise<void> | void)
| undefined,
// `unstagedSources` lets tests simulate partial staging failure: absolute
// source paths listed here are excluded from the returned `staged` map even
@@ -334,6 +338,7 @@ dispatchInboundMessageMock.mockImplementation(
) => void;
replyOptions?: {
runId?: string;
cronCreatorAuthorityCapability?: CronCreatorAuthorityCapability;
onAgentRunStart?: (runId: string) => void;
userTurnTranscriptRecorder?: {
message?: unknown;
@@ -359,7 +364,10 @@ dispatchInboundMessageMock.mockImplementation(
params.replyOptions?.turnAdoptionLifecycle?.originatingLeafEntryId;
mockState.lastTaskSuggestionDeliveryMode = params.replyOptions?.taskSuggestionDeliveryMode;
mockState.lastMessageInjectionAttempted = params.replyOptions?.messageInjectionAttempted;
await mockState.cronAuthorityProbe?.(params.replyOptions?.runId);
await mockState.cronAuthorityProbe?.(
params.replyOptions?.runId,
params.replyOptions?.cronCreatorAuthorityCapability,
);
const recorder = params.replyOptions?.userTurnTranscriptRecorder;
mockState.lastDispatchUserTurnInput = recorder?.resolveMessage
? await recorder.resolveMessage()
@@ -7265,10 +7273,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
});
});
describe("chat.send operator UI client sender context", () => {
describe("chat.send local operator client sender context", () => {
it.each([
[GATEWAY_CLIENT_NAMES.CONTROL_UI, GATEWAY_CLIENT_MODES.WEBCHAT, "web"],
[GATEWAY_CLIENT_NAMES.MACOS_APP, GATEWAY_CLIENT_MODES.UI, "darwin"],
[GATEWAY_CLIENT_NAMES.CLI, GATEWAY_CLIENT_MODES.CLI, "darwin"],
] as const)(
"binds lazy configured-MCP cron authority to an admitted local %s turn",
async (clientId, mode, platform) => {
@@ -7276,13 +7285,27 @@ describe("chat.send operator UI client sender context", () => {
const { send } = createChatRequestFixture();
let retainedResolver: ReturnType<typeof bindActiveCronCreatorAuthorityResolver>;
let resolvedGrant: { runId: string; token: string } | undefined;
mockState.cronAuthorityProbe = async (runId) => {
await runWithCronCreatorAuthorityResolver({
runId: runId ?? "",
resolve: async () => ({
mockState.cronAuthorityProbe = async (runId, capability) => {
await new Promise<void>((resolveTick) => {
setTimeout(resolveTick, 0);
});
const resolve = async () =>
({
tools: ["read", { name: "configured__lookup", pluginId: "bundle-mcp" }],
provenance: { version: 1, source: "final-executable-surface" },
}),
}) as const;
runWithCronCreatorAuthorityCapabilityResolver({
capability,
runId: "other-run",
resolve,
run: () => {
expect(bindActiveCronCreatorAuthorityResolver(runId)).toBeUndefined();
},
});
await runWithCronCreatorAuthorityCapabilityResolver({
capability,
runId,
resolve,
run: async () => {
retainedResolver = bindActiveCronCreatorAuthorityResolver(runId);
const snapshot = await retainedResolver!();
@@ -7320,9 +7343,10 @@ describe("chat.send operator UI client sender context", () => {
it("denies otherwise-eligible internal chat.send re-entry, including Talk consults", async () => {
await createGatewayUserTurnSqliteFixture("openclaw-chat-send-cron-authority-internal-reentry-");
let boundResolver: ReturnType<typeof bindActiveCronCreatorAuthorityResolver>;
mockState.cronAuthorityProbe = async (runId) => {
runWithCronCreatorAuthorityResolver({
runId: runId ?? "",
mockState.cronAuthorityProbe = async (runId, capability) => {
runWithCronCreatorAuthorityCapabilityResolver({
capability,
runId,
resolve: async () => ({
tools: ["read", "configured__lookup"],
provenance: { version: 1, source: "final-executable-surface" },
@@ -7382,6 +7406,11 @@ describe("chat.send operator UI client sender context", () => {
client: { internal: { isLocalClient: true }, scopes: ["operator.admin"] },
requestParams: { systemInputProvenance: { kind: "external_user" } },
},
{
name: "explicit origin",
client: { internal: { isLocalClient: true }, scopes: ["operator.admin"] },
requestParams: { originatingChannel: "slack", originatingTo: "D123" },
},
{
name: "delegated handoff",
client: {
@@ -7412,9 +7441,10 @@ describe("chat.send operator UI client sender context", () => {
await createGatewayUserTurnSqliteFixture("openclaw-chat-send-cron-authority-negative-");
mockState.sessionEntry = testCase.sessionEntry ?? {};
let boundResolver: ReturnType<typeof bindActiveCronCreatorAuthorityResolver>;
mockState.cronAuthorityProbe = async (runId) => {
runWithCronCreatorAuthorityResolver({
runId: runId ?? "",
mockState.cronAuthorityProbe = async (runId, capability) => {
runWithCronCreatorAuthorityCapabilityResolver({
capability,
runId,
resolve: async () => ({
tools: ["read"],
provenance: { version: 1, source: "final-executable-surface" },
+4 -3
View File
@@ -27,6 +27,7 @@ import { resolveCronDeliveryPreviews } from "../../cron/delivery-preview.js";
import { assertCronDeliveryInputNonBlankFields } from "../../cron/delivery-target-validation.js";
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import { toPublicCronJob } from "../../cron/public-job.js";
import type { CronRuntimeAuthority } from "../../cron/runtime-authority.js";
import { CRON_JOB_SCRATCH_MAX_BYTES } from "../../cron/scratch-contract.js";
import { applyJobPatch } from "../../cron/service/jobs.js";
import {
@@ -83,7 +84,7 @@ type CronJobIdParams = { id?: string; jobId?: string };
function resolveCronCreatorAuthorityCommitGuard(
callerScope: CronCallerScope | undefined,
): (() => void) | undefined {
): (() => CronRuntimeAuthority | undefined) | undefined {
const grant = callerScope?.cronCreatorAuthorityGrant;
if (!grant) {
return undefined;
@@ -735,7 +736,7 @@ export const cronHandlers: GatewayRequestHandlers = {
return;
}
const callerScope = readCronCallerScope(client);
let cronCreatorAuthorityCommitGuard: (() => void) | undefined;
let cronCreatorAuthorityCommitGuard: (() => CronRuntimeAuthority | undefined) | undefined;
try {
cronCreatorAuthorityCommitGuard = resolveCronCreatorAuthorityCommitGuard(callerScope);
} catch (err) {
@@ -893,7 +894,7 @@ export const cronHandlers: GatewayRequestHandlers = {
expectedConfigRevision?: string;
};
const callerScope = readCronCallerScope(client);
let cronCreatorAuthorityCommitGuard: (() => void) | undefined;
let cronCreatorAuthorityCommitGuard: (() => CronRuntimeAuthority | undefined) | undefined;
try {
cronCreatorAuthorityCommitGuard = resolveCronCreatorAuthorityCommitGuard(callerScope);
} catch (err) {
+4 -1
View File
@@ -13,7 +13,10 @@ export {
buildCodexUserMcpServersThreadConfigPatchForRuntime,
resolveCodexMcpToolOverridesForAgent,
} from "../agents/cli-runner/bundle-mcp-codex.js";
export { runWithCronCreatorAuthorityResolver } from "../agents/cron-creator-authority-context.js";
export {
runWithCronCreatorAuthorityCapabilityResolver,
runWithCronCreatorAuthorityResolver,
} from "../agents/cron-creator-authority-context.js";
/** Materialize static configured MCP under a scheduled Codex authority envelope. */
export async function materializeStaticMcpToolsForScheduledHarnessRun(