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