diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index d2fd03e56514..cae310052957 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -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 | diff --git a/docs/plugins/codex-native-plugins.md b/docs/plugins/codex-native-plugins.md index 654040c084ea..62f72c9551e1 100644 --- a/docs/plugins/codex-native-plugins.md +++ b/docs/plugins/codex-native-plugins.md @@ -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 diff --git a/extensions/codex/src/app-server/approval-bridge.test.ts b/extensions/codex/src/app-server/approval-bridge.test.ts index 0f79faaa36d4..e31e3e6c4601 100644 --- a/extensions/codex/src/app-server/approval-bridge.test.ts +++ b/extensions/codex/src/app-server/approval-bridge.test.ts @@ -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(); diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index b3cdaa38947a..77962e2e095a 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -101,7 +101,6 @@ export async function handleCodexAppServerApprovalRequest(params: { }); return buildApprovalResponse(params.method, context.requestParams, outcome); }; - try { const policyOutcome = await runOpenClawToolPolicyForApprovalRequest({ method: params.method, diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index 021108c2842e..537f5f55f0cd 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -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[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; } diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index 57f34f9fab6f..22caf2fd7d5f 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -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, diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index 4d8156340dd8..d93d8eff9628 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -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; } diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 50cd15751e97..058510d38d05 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -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, diff --git a/extensions/codex/src/app-server/elicitation-bridge.test.ts b/extensions/codex/src/app-server/elicitation-bridge.test.ts index d3148a0c2def..c124729c8452 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.test.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.test.ts @@ -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" }) diff --git a/extensions/codex/src/app-server/elicitation-bridge.ts b/extensions/codex/src/app-server/elicitation-bridge.ts index b90d7e22c684..dc9abded064c 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.ts @@ -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(); diff --git a/extensions/codex/src/app-server/plugin-thread-config-deadline.ts b/extensions/codex/src/app-server/plugin-thread-config-deadline.ts index b5d0d13769e0..e17b2a557e16 100644 --- a/extensions/codex/src/app-server/plugin-thread-config-deadline.ts +++ b/extensions/codex/src/app-server/plugin-thread-config-deadline.ts @@ -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; }; 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 { - 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; -}): Promise { - 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 { + return await readCurrentCodexScheduledAppPolicyShared({ + request, + configCwd: cwd, + threadId, + }); +} + function resolveCodexPluginThreadConfigTimeoutMs(requestTimeoutMs: number): number { const finiteRequestTimeoutMs = Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 diff --git a/extensions/codex/src/app-server/plugin-thread-config.test.ts b/extensions/codex/src/app-server/plugin-thread-config.test.ts index 615fdb0d34fb..82944c9cdaac 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -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: [], }); diff --git a/extensions/codex/src/app-server/plugin-thread-config.ts b/extensions/codex/src/app-server/plugin-thread-config.ts index 85c1b9db3230..5eefd09cd754 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.ts @@ -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, pluginAppIds: Record, ): PluginAppPolicyContext { diff --git a/extensions/codex/src/app-server/run-attempt-cleanup.ts b/extensions/codex/src/app-server/run-attempt-cleanup.ts index ad001c316303..a5c41bfe5b3f 100644 --- a/extensions/codex/src/app-server/run-attempt-cleanup.ts +++ b/extensions/codex/src/app-server/run-attempt-cleanup.ts @@ -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) { diff --git a/extensions/codex/src/app-server/run-attempt-runtime.authority.test.ts b/extensions/codex/src/app-server/run-attempt-runtime.authority.test.ts index 2c2b72ccfd31..1ba76fd09874 100644 --- a/extensions/codex/src/app-server/run-attempt-runtime.authority.test.ts +++ b/extensions/codex/src/app-server/run-attempt-runtime.authority.test.ts @@ -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 = {}) { + 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/); + }); +}); diff --git a/extensions/codex/src/app-server/run-attempt-runtime.ts b/extensions/codex/src/app-server/run-attempt-runtime.ts index 1b026e4c5883..260dcf513582 100644 --- a/extensions/codex/src/app-server/run-attempt-runtime.ts +++ b/extensions/codex/src/app-server/run-attempt-runtime.ts @@ -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, diff --git a/extensions/codex/src/app-server/run-attempt-tool-setup.ts b/extensions/codex/src/app-server/run-attempt-tool-setup.ts index 74372266f130..1694db372452 100644 --- a/extensions/codex/src/app-server/run-attempt-tool-setup.ts +++ b/extensions/codex/src/app-server/run-attempt-tool-setup.ts @@ -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[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 | undefined; let creatorAuthorityPromise: | Promise<{ tools: readonly (string | { name: string; pluginId?: string })[]; provenance: { version: 1; source: "final-executable-surface" }; + runtimeAuthority?: NonNullable; }> | undefined; let resolveCreatorAuthorityImpl: | ((options?: { signal?: AbortSignal }) => Promise<{ tools: readonly (string | { name: string; pluginId?: string })[]; provenance: { version: 1; source: "final-executable-surface" }; + runtimeAuthority?: NonNullable; }>) | 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 = []; + 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 = []; - 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, diff --git a/extensions/codex/src/app-server/run-attempt-turn-start.ts b/extensions/codex/src/app-server/run-attempt-turn-start.ts index e6b343ff9230..bc989d89b2da 100644 --- a/extensions/codex/src/app-server/run-attempt-turn-start.ts +++ b/extensions/codex/src/app-server/run-attempt-turn-start.ts @@ -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 }; } diff --git a/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts b/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts index 3a06bc32d770..d61966782b28 100644 --- a/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts +++ b/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts @@ -69,15 +69,18 @@ vi.mock("openclaw/plugin-sdk/codex-mcp-projection", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - runWithCronCreatorAuthorityResolver: (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[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) => { mcpMocks.staticCalls.push(params); @@ -202,6 +205,14 @@ function configureFakeMcp(params: ReturnType): void { }; } +function admitLocalOperatorCronAuthority(params: ReturnType): 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; 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((resolve) => { releaseFailure = resolve; diff --git a/extensions/codex/src/app-server/scheduled-app-authority.test.ts b/extensions/codex/src/app-server/scheduled-app-authority.test.ts new file mode 100644 index 000000000000..476372e197c4 --- /dev/null +++ b/extensions/codex/src/app-server/scheduled-app-authority.test.ts @@ -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) { + 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) => { + 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(() => {}); + } + 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 + | 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(() => {}); + } + 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) => { + 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), + ); + }); +}); diff --git a/extensions/codex/src/app-server/scheduled-app-authority.ts b/extensions/codex/src/app-server/scheduled-app-authority.ts new file mode 100644 index 000000000000..bdd80332cda4 --- /dev/null +++ b/extensions/codex/src/app-server/scheduled-app-authority.ts @@ -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; +type CodexAppToolApprovalMode = "auto" | "prompt" | "writes" | "approve"; +export type CurrentCodexScheduledAppPolicy = { + config: Record; + toolNamesByApp: ReadonlyMap>; +}; + +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; + }>; +}; + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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(); + 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 = {}; + 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, +) => Promise; + +async function readCodexScheduledAppToolNamesByApp(params: { + request: CodexScheduledAppPolicyRequest; + threadId?: string; +}): Promise>> { + const toolNamesByApp = new Map>(); + const seenCursors = new Set(); + 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(); + 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 { + 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, + 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; + threadId: string; + policyContext: PluginAppPolicyContext; + profileId: string; + accountId: string; + configCwd?: string; + signal?: AbortSignal; + timeoutMs?: number; +}): Promise { + 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 = { + 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 = {}; + 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(); + 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, +): 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"); +} diff --git a/extensions/codex/src/app-server/scheduled-configured-mcp-authority.ts b/extensions/codex/src/app-server/scheduled-configured-mcp-authority.ts index 2d1e7b180b13..c7d75960b9b2 100644 --- a/extensions/codex/src/app-server/scheduled-configured-mcp-authority.ts +++ b/extensions/codex/src/app-server/scheduled-configured-mcp-authority.ts @@ -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 && diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index c824ffbe010c..71530d7c41df 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -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: [], }, diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 96ae0f183ecb..ec073efd92bc 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -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[], }; diff --git a/extensions/codex/src/app-server/thread-lifecycle-io.ts b/extensions/codex/src/app-server/thread-lifecycle-io.ts index 75e5861f3e32..8026cd893850 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-io.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-io.ts @@ -91,6 +91,7 @@ type ThreadRequestContext = { type ResumeThreadContext = ThreadRequestContext & { binding: CodexAppServerThreadBinding; clearCurrentBinding: (operation: string) => Promise; + 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>; diff --git a/extensions/codex/src/app-server/thread-lifecycle-run.ts b/extensions/codex/src/app-server/thread-lifecycle-run.ts index b8ca026e579b..9cf13f213851 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-run.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-run.ts @@ -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; diff --git a/extensions/codex/src/app-server/thread-lifecycle-types.ts b/extensions/codex/src/app-server/thread-lifecycle-types.ts index abc40fb5e993..d16a638246ba 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-types.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-types.ts @@ -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; + build: (options?: { threadId?: string }) => Promise; }; export type CodexStartOrResumeThreadParams = { diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index 60169e0013fa..635e247c87c7 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -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> = []; + const request = vi.fn(async (method: string, requestParams?: unknown) => { + if (method === "thread/start") { + threadSequence += 1; + threadStarts.push(requestParams as Record); + 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>; + 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", () => { diff --git a/extensions/codex/src/app-server/timeout.ts b/extensions/codex/src/app-server/timeout.ts index c7dc9c696b57..355a689fcdb6 100644 --- a/extensions/codex/src/app-server/timeout.ts +++ b/extensions/codex/src/app-server/timeout.ts @@ -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( promise: Promise, @@ -16,3 +22,38 @@ export async function withTimeout( ...(createError ? { createError } : {}), }); } + +/** Bounds an operation by both its owner lifecycle and one total wall-clock budget. */ +export async function withAbortableTimeout(params: { + promise: Promise; + timeoutMs: number; + signal?: AbortSignal; + timeoutMessage: string; + createTimeoutError?: () => Error; +}): Promise { + const signal = params.signal; + if (signal?.aborted) { + throw resolveAbortError(signal); + } + let removeAbortListener: (() => void) | undefined; + const operation = signal + ? Promise.race([ + params.promise, + new Promise((_, 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?.(); + } +} diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index 30ad53e6a3ea..02accc9aa415 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -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) | 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) => + 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( diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 800acb55edd0..c2ab94399406 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -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, diff --git a/src/agents/cron-creator-authority-context.ts b/src/agents/cron-creator-authority-context.ts index 8c46d0012577..16a0b1214e1f 100644 --- a/src/agents/cron-creator-authority-context.ts +++ b/src/agents/cron-creator-authority-context.ts @@ -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; +type CronCreatorAuthorityMaterializer = (options?: { + signal?: AbortSignal; +}) => Promise; type CronCreatorAuthorityResolverScope = { - resolve: (options?: { signal?: AbortSignal }) => Promise; + 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(); const activeCronCreatorAuthorityResolver = new AsyncLocalStorage(); +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( runId: string, @@ -33,6 +68,15 @@ export function runWithCronCreatorAuthority( return run(); } const scope = createCronCreatorAuthorityRunScope(normalizedRunId); + return runWithCronCreatorAuthorityCapability(scope, run, signal); +} + +/** Owns one explicitly transported creator-authority capability until run settlement. */ +export function runWithCronCreatorAuthorityCapability( + 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( } } +/** 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(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(params: { runId: string; - resolve: (options?: { signal?: AbortSignal }) => Promise; + 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, + }); } diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index ca2c45004feb..b293e5d695d7 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -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). */ diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index 1fff6365dcf0..637985e24229 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -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, diff --git a/src/agents/tools/cron-tool.types.ts b/src/agents/tools/cron-tool.types.ts index f2ad308e8038..d26a78a4db03 100644 --- a/src/agents/tools/cron-tool.types.ts +++ b/src/agents/tools/cron-tool.types.ts @@ -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; }; diff --git a/src/auto-reply/reply/agent-runner-embedded-candidate.ts b/src/auto-reply/reply/agent-runner-embedded-candidate.ts index 32d8765afd14..0dbac82a35d9 100644 --- a/src/auto-reply/reply/agent-runner-embedded-candidate.ts +++ b/src/auto-reply/reply/agent-runner-embedded-candidate.ts @@ -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, diff --git a/src/auto-reply/reply/get-reply-run-execute.test.ts b/src/auto-reply/reply/get-reply-run-execute.test.ts new file mode 100644 index 000000000000..d324a2d31cea --- /dev/null +++ b/src/auto-reply/reply/get-reply-run-execute.test.ts @@ -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); + }); +}); diff --git a/src/auto-reply/reply/get-reply-run-execute.ts b/src/auto-reply/reply/get-reply-run-execute.ts index 4e91497a1f05..46db0c22417d 100644 --- a/src/auto-reply/reply/get-reply-run-execute.ts +++ b/src/auto-reply/reply/get-reply-run-execute.ts @@ -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(); } diff --git a/src/auto-reply/reply/get-reply.types.ts b/src/auto-reply/reply/get-reply.types.ts index 442f6879e494..ca03c31ce72a 100644 --- a/src/auto-reply/reply/get-reply.types.ts +++ b/src/auto-reply/reply/get-reply.types.ts @@ -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; diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index bb02d0e33932..50d8e5cf3290 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -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 | 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>; 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; diff --git a/src/cron/isolated-agent/run.payload-fallbacks.test.ts b/src/cron/isolated-agent/run.payload-fallbacks.test.ts index 9c8a4f2390ad..187363c98aa6 100644 --- a/src/cron/isolated-agent/run.payload-fallbacks.test.ts +++ b/src/cron/isolated-agent/run.payload-fallbacks.test.ts @@ -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(); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index cb47d2a8d647..2a0839946eb6 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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; diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index d850d04cb070..bbdd65f125cd 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -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) { diff --git a/src/cron/public-job.test.ts b/src/cron/public-job.test.ts index bcf0f381173f..8f0b13fd91c3 100644 --- a/src/cron/public-job.test.ts +++ b/src/cron/public-job.test.ts @@ -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); + }); }); diff --git a/src/cron/public-job.ts b/src/cron/public-job.ts index 0730341da651..1f9e8dbc22cf 100644 --- a/src/cron/public-job.ts +++ b/src/cron/public-job.ts @@ -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; diff --git a/src/cron/runtime-authority.test.ts b/src/cron/runtime-authority.test.ts new file mode 100644 index 000000000000..ae438d37edb6 --- /dev/null +++ b/src/cron/runtime-authority.test.ts @@ -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 = {}; + cyclic.self = cyclic; + let deep: Record = {}; + 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; + + 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(); + }); +}); diff --git a/src/cron/runtime-authority.ts b/src/cron/runtime-authority.ts new file mode 100644 index 000000000000..4bf73903b710 --- /dev/null +++ b/src/cron/runtime-authority.ts @@ -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>; +}>; + +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, + 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 = Object.create(null) as Record; + 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 | 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) + : 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>, + } 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); +} diff --git a/src/cron/service/ops-mutations.ts b/src/cron/service/ops-mutations.ts index c92c4337469d..12cd3f78aea9 100644 --- a/src/cron/service/ops-mutations.ts +++ b/src/cron/service/ops-mutations.ts @@ -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; diff --git a/src/cron/service/ops.test.ts b/src/cron/service/ops.test.ts index 48047a52166d..2053f87a9846 100644 --- a/src/cron/service/ops.test.ts +++ b/src/cron/service/ops.test.ts @@ -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 }), diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index b7ece5c03fa0..cb01bf961d44 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -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 = { diff --git a/src/cron/store/row-codec.schedule.test.ts b/src/cron/store/row-codec.schedule.test.ts index 049f9e9321d9..e6e715261882 100644 --- a/src/cron/store/row-codec.schedule.test.ts +++ b/src/cron/store/row-codec.schedule.test.ts @@ -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" } }), diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts index 51b00097628d..cc1733fe1c10 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -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): 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): 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, diff --git a/src/cron/types.ts b/src/cron/types.ts index b47d8ee06a52..97c57c5d8a17 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -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; diff --git a/src/gateway/cron-creator-authority-grant.test.ts b/src/gateway/cron-creator-authority-grant.test.ts index 672495c78654..2c842366ff44 100644 --- a/src/gateway/cron-creator-authority-grant.test.ts +++ b/src/gateway/cron-creator-authority-grant.test.ts @@ -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); + }); }); diff --git a/src/gateway/cron-creator-authority-grant.ts b/src/gateway/cron-creator-authority-grant.ts index 8fc5a3c9c02c..270d569d83ef 100644 --- a/src/gateway/cron-creator-authority-grant.ts +++ b/src/gateway/cron-creator-authority-grant.ts @@ -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; } diff --git a/src/gateway/server-methods/chat-send-external-authority-contract.ts b/src/gateway/server-methods/chat-send-external-authority-contract.ts index 17812f978fb9..cf9f871f26d7 100644 --- a/src/gateway/server-methods/chat-send-external-authority-contract.ts +++ b/src/gateway/server-methods/chat-send-external-authority-contract.ts @@ -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(authority: Readonly<{ runId: string }>, run: () => T, signal?: AbortSignal): T; + run(capability: CronCreatorAuthorityCapability, run: () => T, signal?: AbortSignal): T; }; diff --git a/src/gateway/server-methods/chat-send-external-entry.ts b/src/gateway/server-methods/chat-send-external-entry.ts index a2473151bd6e..4a1672f32280 100644 --- a/src/gateway/server-methods/chat-send-external-entry.ts +++ b/src/gateway/server-methods/chat-send-external-entry.ts @@ -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. */ diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts index a59656ee19d8..f6930d3201da 100644 --- a/src/gateway/server-methods/chat-send-handler.ts +++ b/src/gateway/server-methods/chat-send-handler.ts @@ -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, ) diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 4052ac229b61..7b1627602897 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -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> | null, runtimeAssistantTextsBeforeDelivery: [] as string[], cronAuthorityProbe: undefined as - | ((runId: string | undefined) => Promise | void) + | (( + runId: string | undefined, + capability: CronCreatorAuthorityCapability | undefined, + ) => Promise | 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; 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((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; - 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; - 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" }, diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 1b62d7f4e198..280ad28296cb 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -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) { diff --git a/src/plugin-sdk/codex-mcp-projection.ts b/src/plugin-sdk/codex-mcp-projection.ts index feb5900cabcb..52b81b77f98b 100644 --- a/src/plugin-sdk/codex-mcp-projection.ts +++ b/src/plugin-sdk/codex-mcp-projection.ts @@ -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(