Compare commits

..

25 Commits

Author SHA1 Message Date
Vincent Koc
46584db0c1 fix(i18n): restrict native UI extraction 2026-06-26 18:04:47 -07:00
Vincent Koc
897214d248 fix(i18n): guard native refresh inputs 2026-06-26 18:04:47 -07:00
Vincent Koc
eff4ab356c fix(i18n): cover all native source roots 2026-06-26 18:04:46 -07:00
Vincent Koc
dba00247ca fix(i18n): validate Kotlin and Swift placeholders 2026-06-26 18:04:46 -07:00
Vincent Koc
5cbf6928d9 fix(ci): restrict native locale refresh dispatch 2026-06-26 18:04:46 -07:00
Vincent Koc
1c54e76223 fix(i18n): validate native translation structure 2026-06-26 18:04:46 -07:00
Vincent Koc
bd38ea44d0 docs(i18n): clarify native artifact ownership 2026-06-26 18:04:46 -07:00
Vincent Koc
aa2c87fcc9 fix(ci): commit first native locale artifacts 2026-06-26 18:04:46 -07:00
Vincent Koc
e51433b092 feat(i18n): refresh every native locale 2026-06-26 18:04:46 -07:00
Vincent Koc
90855f194e feat(i18n): refresh native locale artifacts 2026-06-26 18:04:46 -07:00
Vincent Koc
afbbd2ab16 fix(i18n): restrict native UI extraction 2026-06-26 18:04:06 -07:00
Vincent Koc
9580fad305 fix(i18n): filter non-translatable native literals 2026-06-26 17:58:34 -07:00
Vincent Koc
9df3467360 fix(i18n): cover all native source roots 2026-06-26 17:54:21 -07:00
Vincent Koc
ac70e9ddda fix(i18n): inventory conditional native labels 2026-06-26 17:49:14 -07:00
Vincent Koc
bfca9b2447 fix(i18n): align native scan scope and build exclusions 2026-06-26 17:44:07 -07:00
Vincent Koc
3d06c4bc24 feat(i18n): inventory native resources and wrappers 2026-06-26 17:39:57 -07:00
Vincent Koc
8f9aca8aaa fix(i18n): parse native interpolation expressions 2026-06-26 17:31:58 -07:00
Vincent Koc
6f0d8c2097 fix(i18n): preserve Kotlin native placeholders 2026-06-26 17:26:37 -07:00
Vincent Koc
c5884957ff ci(i18n): run native checks for tooling changes 2026-06-26 17:21:49 -07:00
Vincent Koc
22d0780a89 fix(i18n): preserve native placeholders and whitespace 2026-06-26 17:16:33 -07:00
Vincent Koc
126fc2f0b4 fix(i18n): skip non-runtime native source literals 2026-06-26 17:11:47 -07:00
Vincent Koc
67cf97ef55 fix(i18n): guard native inventory in CI 2026-06-26 17:07:02 -07:00
Vincent Koc
8cbd6c78c8 fix(i18n): keep native refresh inventory clean 2026-06-26 17:03:11 -07:00
Vincent Koc
1545198f8b feat(i18n): define native locale matrix 2026-06-26 16:55:51 -07:00
Vincent Koc
20f5648a2e feat(i18n): inventory native app UI strings 2026-06-26 16:55:51 -07:00
176 changed files with 19120 additions and 10836 deletions

View File

@@ -848,6 +848,28 @@ jobs:
path: .local/gateway-watch-regression/
retention-days: 7
native-i18n:
permissions:
contents: read
needs: [preflight]
if: ${{ !cancelled() && always() && (needs.preflight.outputs.run_macos == 'true' || needs.preflight.outputs.run_android == 'true' || needs.preflight.outputs.run_node == 'true') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check native app i18n inventory
run: pnpm native:i18n:check
checks-fast-core:
permissions:
contents: read

View File

@@ -0,0 +1,119 @@
name: Native App Locale Refresh
on:
push:
branches:
- main
paths:
- apps/android/app/src/main/**
- apps/ios/**
- apps/macos/Sources/**
- apps/macos/Package.swift
- apps/shared/OpenClawKit/Sources/**
- apps/.i18n/native-source.json
- scripts/control-ui-i18n.ts
- scripts/native-app-i18n.ts
- .github/workflows/native-app-locale-refresh.yml
workflow_dispatch:
permissions:
contents: write
concurrency:
group: native-app-locale-refresh-${{ github.event_name == 'push' && github.ref || format('manual-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
jobs:
refresh:
if: github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') && (github.event_name != 'push' || github.actor != 'github-actions[bot]')
strategy:
fail-fast: false
max-parallel: 2
matrix:
locale:
[
zh-CN,
zh-TW,
pt-BR,
de,
es,
ja-JP,
ko,
fr,
hi,
ar,
it,
tr,
uk,
id,
pl,
th,
vi,
nl,
fa,
ru,
]
runs-on: ubuntu-latest
name: Refresh native ${{ matrix.locale }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Ensure translation provider secrets exist
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
set -euo pipefail
if [ -z "${OPENAI_API_KEY:-}" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
echo "Missing OPENCLAW_DOCS_I18N_OPENAI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY secret."
exit 1
fi
- name: Refresh native locale artifact
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-8' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
OPENCLAW_CONTROL_UI_I18N_THINKING: low
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "0"
LOCALE: ${{ matrix.locale }}
run: node --import tsx scripts/native-app-i18n.ts sync --write --locale "${LOCALE}"
- name: Commit and push locale artifact
env:
LOCALE: ${{ matrix.locale }}
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
if ! git status --porcelain -- apps/.i18n/native apps/.i18n/native-source.json | grep -q .; then
echo "No native locale changes for ${LOCALE}."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A apps/.i18n/native apps/.i18n/native-source.json
git commit --no-verify -m "chore(i18n): refresh native ${LOCALE} locale"
for attempt in 1 2 3 4 5; do
git fetch origin "${TARGET_BRANCH}"
git rebase --autostash "origin/${TARGET_BRANCH}"
if git push origin HEAD:"${TARGET_BRANCH}"; then
exit 0
fi
echo "Push attempt ${attempt} for ${LOCALE} failed; retrying."
sleep $((attempt * 2))
done
echo "Failed to push ${LOCALE} native locale update after retries."
exit 1

17421
apps/.i18n/native-source.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -316,11 +316,6 @@ conversation bindings, or any non-Codex harness.
plugin/app support for the Codex harness. Default: `false`.
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions`:
default destructive-action policy for migrated plugin app elicitations.
Use `true` to accept safe Codex approval schemas without prompting, `false`
to decline them, `"auto"` to route Codex-required approvals through OpenClaw
plugin approvals, or `"always"` to ask for every plugin write/destructive
action without durable approval. The `"always"` mode clears durable Codex
per-tool approval overrides for the affected app before starting the thread.
Default: `true`.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.enabled`: enables a
migrated plugin entry when global `codexPlugins.enabled` is also true.
@@ -331,8 +326,7 @@ conversation bindings, or any non-Codex harness.
Codex plugin identity from migration, for example `"google-calendar"`.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
per-plugin destructive-action override. When omitted, the global
`allow_destructive_actions` value is used. The per-plugin value accepts the
same `true`, `false`, `"auto"`, or `"always"` policies.
`allow_destructive_actions` value is used.
`codexPlugins.enabled` is the global enablement directive. Explicit plugin
entries written by migration are the durable install and repair eligibility set.

View File

@@ -200,11 +200,11 @@ enabled.
OpenClaw sets app-level `destructive_enabled` from the effective global or
per-plugin `allow_destructive_actions` policy and lets Codex enforce
destructive tool metadata from its native app tool annotations. `true`,
`"auto"`, and `"always"` set `destructive_enabled: true`; `false` sets it
false. The `_default` app config is disabled with `open_world_enabled: false`.
Enabled plugin apps are emitted with `open_world_enabled: true`; OpenClaw does
not expose a separate plugin open-world policy knob and does not maintain
destructive tool metadata from its native app tool annotations. `true` and
`"auto"` both set `destructive_enabled: true`; `false` sets it false. The
`_default` app config is disabled with `open_world_enabled: false`. Enabled
plugin apps are emitted with `open_world_enabled: true`; OpenClaw does not
expose a separate plugin open-world policy knob and does not maintain
per-plugin destructive tool-name deny lists.
Tool approval mode is automatic by default for plugin apps so non-destructive
@@ -225,10 +225,6 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
- When policy is `"auto"`, OpenClaw exposes destructive plugin actions to
Codex but turns ownership-proven MCP approval elicitations into OpenClaw
plugin approvals before returning the Codex approval response.
- When policy is `"always"`, OpenClaw uses the same Codex write/destructive
gating as `"auto"`, clears durable Codex per-tool approval overrides for the
app before the thread starts, and only offers one-shot approval or denial so
durable approvals cannot suppress later write-action prompts.
- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn
id, or an unsafe elicitation schema declines instead of prompting.
@@ -276,9 +272,8 @@ Codex thread bindings keep the app config they started with until OpenClaw
establishes a new harness session or replaces a stale binding.
**Destructive action is declined:** check the global and per-plugin
`allow_destructive_actions` values. Even when policy is true, `"auto"`, or
`"always"`, unsafe elicitation schemas and ambiguous plugin identity still fail
closed.
`allow_destructive_actions` values. Even when policy is true or `"auto"`,
unsafe elicitation schemas and ambiguous plugin identity still fail closed.
## Related

View File

@@ -211,18 +211,6 @@ each carrier call should start with fresh context, for example reception,
booking, IVR, or Google Meet bridge flows where the same phone number may
represent different meetings.
Voice Call stores generated session keys under the configured agent namespace
(`agent:<agentId>:voice:*`) so call memory survives Gateway session-key
canonicalization after restarts. Raw explicit integration keys use the same
agent namespace. A canonical `agent:<configuredAgentId>:*` key keeps that owner,
and its main aliases honor core `session.mainKey` and global scope. Foreign or
malformed `agent:*` input is scoped as an opaque key under the configured agent;
`global` and `unknown` remain global sentinels. Gateway startup promotes older
raw keys in default or `{agentId}`-templated stores where the path proves one
owner. In fixed custom stores, ambiguous legacy rows remain untouched because
they do not contain enough information to choose an owner; new calls use
canonical agent-scoped history.
## Realtime voice conversations
`realtime` selects a full-duplex realtime voice provider for live call

View File

@@ -192,109 +192,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
);
});
it("adds the OpenClaw session key to the managed OpenClaw tools MCP bridge", () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const readScopedMcpEnv = (sessionKey: string) => {
const delegate = (
runtime as unknown as {
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
}
).resolveOpenClawToolsDelegateForSession(sessionKey) as {
options: {
mcpServers?: Array<{
env?: Array<{ name: string; value: string }>;
name: string;
}>;
};
};
return delegate.options.mcpServers?.find((server) => server.name === "openclaw-tools")?.env;
};
expect(readScopedMcpEnv("agent:worker:main")).toContainEqual({
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
value: "agent:worker:main",
});
expect(readScopedMcpEnv("agent:research:main")).toContainEqual({
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
value: "agent:research:main",
});
});
it("keeps managed OpenClaw tools MCP delegates reachable for fresh sessions", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const exposedRuntime = runtime as unknown as {
openclawToolsSessionDelegates: Map<string, unknown>;
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
};
const firstDelegate =
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main");
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
await runtime.prepareFreshSession({ sessionKey: "agent:worker:main" });
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
expect(exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main")).toBe(
firstDelegate,
);
});
it("uses the no-MCP delegate for startup probes when the OpenClaw tools bridge is enabled", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const defaultProbe = vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined);
const safeProbe = vi
.spyOn(bridgeSafeDelegate, "probeAvailability")
.mockResolvedValue(undefined);
await runtime.probeAvailability();
expect(safeProbe).toHaveBeenCalledTimes(1);
expect(defaultProbe).not.toHaveBeenCalled();
});
it("normalizes OpenClaw Codex model ids for ACP startup", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
@@ -1266,46 +1163,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
expect(baseStore["load"]).toHaveBeenCalledOnce();
});
it("releases managed OpenClaw tools MCP delegates after close", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const exposedRuntime = runtime as unknown as {
openclawToolsSessionDelegates: Map<string, { close: AcpRuntime["close"] }>;
resolveOpenClawToolsDelegateForSession(sessionKey: string): {
close: AcpRuntime["close"];
};
};
const scopedDelegate =
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:codex:main");
const close = vi.spyOn(scopedDelegate, "close").mockResolvedValue(undefined);
await runtime.close({
handle: {
sessionKey: "agent:codex:main",
backend: "acpx",
runtimeSessionName: "agent:codex:main",
},
reason: "closed",
});
expect(close).toHaveBeenCalledOnce();
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:codex:main")).toBe(false);
});
it("cleans up OpenClaw-owned ACPX process trees after close", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => ({

View File

@@ -50,7 +50,6 @@ type OpenClawAcpxRuntimeOptions = AcpRuntimeOptions & {
openclawWrapperRoot?: string;
openclawGatewayInstanceId?: string;
openclawProcessLeaseStore?: AcpxProcessLeaseStore;
openclawToolsMcpBridgeEnabled?: boolean;
};
type AcpxRuntimeTestOptions = Record<string, unknown> & {
openclawProcessCleanup?: AcpxProcessCleanupDeps;
@@ -58,10 +57,6 @@ type AcpxRuntimeTestOptions = Record<string, unknown> & {
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
type AcpxMcpServer = NonNullable<AcpRuntimeOptions["mcpServers"]>[number];
const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY";
type ResetAwareSessionStore = AcpSessionStore & {
markFresh: (sessionKey: string) => void;
@@ -687,33 +682,6 @@ function shouldUseDistinctBridgeDelegate(options: AcpRuntimeOptions): boolean {
return Array.isArray(mcpServers) && mcpServers.length > 0;
}
function withOpenClawToolsMcpSessionEnv(params: {
enabled: boolean | undefined;
mcpServers: AcpRuntimeOptions["mcpServers"];
sessionKey: string;
}): AcpRuntimeOptions["mcpServers"] {
const sessionKey = params.sessionKey.trim();
if (!params.enabled || !sessionKey || !params.mcpServers?.length) {
return params.mcpServers;
}
let changed = false;
const nextServers = params.mcpServers.map((server): AcpxMcpServer => {
if (server.name !== ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME || !("command" in server)) {
return server;
}
changed = true;
const env = [
...server.env.filter((entry) => entry.name !== OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV),
{
name: OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
value: sessionKey,
},
];
return { ...server, env };
});
return changed ? nextServers : params.mcpServers;
}
/** OpenClaw-managed ACP runtime implementation backed by the upstream acpx runtime. */
export class AcpxRuntime implements AcpRuntime {
private readonly sessionStore: ResetAwareSessionStore;
@@ -725,10 +693,6 @@ export class AcpxRuntime implements AcpRuntime {
private readonly delegate: BaseAcpxRuntime;
private readonly bridgeSafeDelegate: BaseAcpxRuntime;
private readonly probeDelegate: BaseAcpxRuntime;
private readonly delegateOptions: AcpRuntimeOptions;
private readonly delegateTestOptions: BaseAcpxRuntimeTestOptions;
private readonly openclawToolsMcpBridgeEnabled: boolean;
private readonly openclawToolsSessionDelegates = new Map<string, BaseAcpxRuntime>();
private readonly processCleanupDeps: AcpxProcessCleanupDeps | undefined;
private readonly wrapperRoot: string | undefined;
private readonly gatewayInstanceId: string | undefined;
@@ -742,7 +706,6 @@ export class AcpxRuntime implements AcpRuntime {
this.wrapperRoot = options.openclawWrapperRoot;
this.gatewayInstanceId = options.openclawGatewayInstanceId;
this.processLeaseStore = options.openclawProcessLeaseStore;
this.openclawToolsMcpBridgeEnabled = options.openclawToolsMcpBridgeEnabled === true;
this.cwd = options.cwd;
this.sessionStore = createResetAwareSessionStore(options.sessionStore, {
gatewayInstanceId: this.gatewayInstanceId,
@@ -760,21 +723,20 @@ export class AcpxRuntime implements AcpRuntime {
sessionStore: this.sessionStore,
agentRegistry: this.scopedAgentRegistry,
};
this.delegateOptions = sharedOptions;
this.delegateTestOptions = delegateTestOptions as BaseAcpxRuntimeTestOptions;
this.delegate = new BaseAcpxRuntime(sharedOptions, this.delegateTestOptions);
this.delegate = new BaseAcpxRuntime(
sharedOptions,
delegateTestOptions as BaseAcpxRuntimeTestOptions,
);
this.bridgeSafeDelegate = shouldUseDistinctBridgeDelegate(options)
? new BaseAcpxRuntime(
{
...sharedOptions,
mcpServers: [],
},
this.delegateTestOptions,
delegateTestOptions as BaseAcpxRuntimeTestOptions,
)
: this.delegate;
this.probeDelegate = this.openclawToolsMcpBridgeEnabled
? this.bridgeSafeDelegate
: this.resolveDelegateForAgent(resolveProbeAgentName(options));
this.probeDelegate = this.resolveDelegateForAgent(resolveProbeAgentName(options));
}
private resolveDelegateForAgent(agentName: string | undefined): BaseAcpxRuntime {
@@ -789,57 +751,6 @@ export class AcpxRuntime implements AcpRuntime {
return shouldUseBridgeSafeDelegateForCommand(command) ? this.bridgeSafeDelegate : this.delegate;
}
private resolveDelegateForSession(params: {
command: string | undefined;
sessionKey: string;
}): BaseAcpxRuntime {
if (shouldUseBridgeSafeDelegateForCommand(params.command)) {
return this.bridgeSafeDelegate;
}
return this.resolveOpenClawToolsDelegateForSession(params.sessionKey);
}
private resolveOpenClawToolsDelegateForSession(sessionKey: string): BaseAcpxRuntime {
if (!this.openclawToolsMcpBridgeEnabled) {
return this.delegate;
}
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) {
return this.delegate;
}
const cached = this.openclawToolsSessionDelegates.get(normalizedSessionKey);
if (cached) {
return cached;
}
// Upstream acpx captures mcpServers at runtime construction. The managed
// OpenClaw tools bridge needs per-session identity, so cache one delegate
// per session with the scoped MCP env already embedded.
const delegate = new BaseAcpxRuntime(
{
...this.delegateOptions,
mcpServers: withOpenClawToolsMcpSessionEnv({
enabled: this.openclawToolsMcpBridgeEnabled,
mcpServers: this.delegateOptions.mcpServers,
sessionKey: normalizedSessionKey,
}),
},
this.delegateTestOptions,
);
this.openclawToolsSessionDelegates.set(normalizedSessionKey, delegate);
return delegate;
}
private releaseOpenClawToolsDelegateForSession(sessionKey: string): void {
if (!this.openclawToolsMcpBridgeEnabled) {
return;
}
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) {
return;
}
this.openclawToolsSessionDelegates.delete(normalizedSessionKey);
}
private async resolveDelegateForHandle(handle: AcpRuntimeHandle): Promise<BaseAcpxRuntime> {
const record = await this.sessionStore.load(handle.acpxRecordId ?? handle.sessionKey);
return this.resolveDelegateForLoadedRecord(handle, record);
@@ -851,17 +762,9 @@ export class AcpxRuntime implements AcpRuntime {
): BaseAcpxRuntime {
const recordCommand = readAgentCommandFromRecord(record);
if (recordCommand) {
return this.resolveDelegateForSession({
command: recordCommand,
sessionKey: handle.sessionKey,
});
return this.resolveDelegateForCommand(recordCommand);
}
const agentName = readAgentFromHandle(handle);
const command = resolveAgentCommandForName({
agentName,
agentRegistry: this.agentRegistry,
});
return this.resolveDelegateForSession({ command, sessionKey: handle.sessionKey });
return this.resolveDelegateForAgent(readAgentFromHandle(handle));
}
private async resolveCommandForHandle(handle: AcpRuntimeHandle): Promise<string | undefined> {
@@ -1077,7 +980,7 @@ export class AcpxRuntime implements AcpRuntime {
agentName: input.agent,
agentRegistry: this.agentRegistry,
});
const delegate = this.resolveDelegateForSession({ command, sessionKey: input.sessionKey });
const delegate = this.resolveDelegateForCommand(command);
const claudeModelOverride = isClaudeAcpCommand(command)
? normalizeClaudeAcpModelOverride(input.model)
: undefined;
@@ -1361,9 +1264,6 @@ export class AcpxRuntime implements AcpRuntime {
}
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
// Fresh reset has no ACP handle to close the delegate's upstream client.
// Keep the scoped delegate reachable so the next ensure can replace it;
// close() owns cache release when the session lifecycle ends.
this.sessionStore.markFresh(input.sessionKey);
}
@@ -1372,9 +1272,8 @@ export class AcpxRuntime implements AcpRuntime {
input.handle.acpxRecordId ?? input.handle.sessionKey,
);
let closeSucceeded;
const delegate = this.resolveDelegateForLoadedRecord(input.handle, record);
try {
await delegate.close({
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
handle: input.handle,
reason: input.reason,
discardPersistentState: input.discardPersistentState,
@@ -1383,9 +1282,6 @@ export class AcpxRuntime implements AcpRuntime {
} finally {
await this.cleanupProcessTreeForRecord(input.handle, record);
}
if (closeSucceeded) {
this.releaseOpenClawToolsDelegateForSession(input.handle.sessionKey);
}
if (closeSucceeded && input.discardPersistentState) {
this.sessionStore.markFresh(input.handle.sessionKey);
}

View File

@@ -111,7 +111,6 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime
}),
probeAgent: params.pluginConfig.probeAgent,
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
openclawToolsMcpBridgeEnabled: params.pluginConfig.openClawToolsMcpBridge,
permissionMode: params.pluginConfig.permissionMode,
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
timeoutMs: resolveAcpxTimerTimeoutMs(params.pluginConfig.timeoutSeconds),

View File

@@ -36,14 +36,6 @@ describe("codex doctor contract", () => {
},
}),
).toBe(false);
expect(
legacyConfigRules[1]?.match({
allow_destructive_actions: "always",
plugins: {
"google-calendar": { allow_destructive_actions: "always" },
},
}),
).toBe(false);
});
it("removes the retired dynamic tools profile without dropping other Codex config", () => {

View File

@@ -101,7 +101,7 @@
"default": false
},
"allow_destructive_actions": {
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }],
"oneOf": [{ "type": "boolean" }, { "const": "auto" }],
"default": true
},
"plugins": {
@@ -121,7 +121,7 @@
"type": "string"
},
"allow_destructive_actions": {
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }]
"oneOf": [{ "type": "boolean" }, { "const": "auto" }]
}
}
}
@@ -343,7 +343,7 @@
},
"codexPlugins.allow_destructive_actions": {
"label": "Allow Destructive Plugin Actions",
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, auto to ask through plugin approvals when Codex requires approval, or always to ask for every write/destructive action without durable approval.",
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, or auto to ask through plugin approvals.",
"advanced": true
},
"codexPlugins.plugins": {

View File

@@ -346,7 +346,6 @@ export async function startCodexAttemptThread(params: {
timeoutMs: params.appServer.requestTimeoutMs,
signal,
}),
configCwd: startupExecutionCwd,
appCache: defaultCodexAppInventoryCache,
appCacheKey: pluginAppCacheKey,
}),

View File

@@ -1192,52 +1192,6 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
});
});
it("parses always native Codex plugin destructive policy", () => {
const config = readCodexPluginConfig({
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
slack: {
marketplaceName: "openai-curated",
pluginName: "slack",
allow_destructive_actions: "auto",
},
},
},
});
expect(config.codexPlugins?.allow_destructive_actions).toBe("always");
expect(resolveCodexPluginsPolicy(config)).toEqual({
configured: true,
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "always",
pluginPolicies: [
{
configKey: "google-calendar",
marketplaceName: "openai-curated",
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "always",
},
{
configKey: "slack",
marketplaceName: "openai-curated",
pluginName: "slack",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
},
],
});
});
it("rejects unsupported native Codex plugin destructive policy strings", () => {
const config = readCodexPluginConfig({
codexPlugins: {

View File

@@ -74,8 +74,8 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange
type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
export type CodexDynamicToolsLoading = "searchable" | "direct";
export type CodexPluginDestructivePolicy = boolean | "auto" | "always";
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto" | "always";
export type CodexPluginDestructivePolicy = boolean | "auto";
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto";
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
@@ -311,11 +311,7 @@ const codexAppServerApprovalPolicySchema = z.enum([
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]);
const codexPluginDestructivePolicySchema = z.union([
z.boolean(),
z.literal("auto"),
z.literal("always"),
]);
const codexPluginDestructivePolicySchema = z.union([z.boolean(), z.literal("auto")]);
const codexAppServerServiceTierSchema = z
.preprocess(
(value) => (value === null ? null : normalizeCodexServiceTier(value)),
@@ -499,8 +495,8 @@ function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolic
allowDestructiveActions: boolean;
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
} {
if (policy === "auto" || policy === "always") {
return { allowDestructiveActions: true, destructiveApprovalMode: policy };
if (policy === "auto") {
return { allowDestructiveActions: true, destructiveApprovalMode: "auto" };
}
return {
allowDestructiveActions: policy,

View File

@@ -157,7 +157,7 @@ function buildConnectorPluginApprovalElicitation(overrides: Record<string, unkno
function createPluginAppPolicyContext(
params: {
allowDestructiveActions?: boolean;
destructiveApprovalMode?: "allow" | "deny" | "auto" | "always";
destructiveApprovalMode?: "allow" | "deny" | "auto";
apps?: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>;
} = {},
) {
@@ -1017,96 +1017,6 @@ describe("Codex app-server elicitation bridge", () => {
});
});
it("does not expose allow-always for always plugin policy", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-always-policy", status: "accepted" })
.mockResolvedValueOnce({
id: "plugin:approval-calendar-always-policy",
decision: "allow-once",
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation({
_meta: {
codex_approval_kind: "mcp_tool_call",
source: "connector",
connector_id: "connector_google_calendar",
connector_name: "Google Calendar",
persist: ["session", "always"],
tool_title: "create_event",
},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "always",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({
action: "accept",
content: null,
_meta: null,
});
expect(gatewayToolArg(0, 2)).toMatchObject({
allowedDecisions: ["allow-once", "deny"],
});
});
it("maps unexpected allow-always decisions to one-shot for always plugin policy", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({
id: "plugin:approval-calendar-unexpected-always",
status: "accepted",
})
.mockResolvedValueOnce({
id: "plugin:approval-calendar-unexpected-always",
decision: "allow-always",
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation({
_meta: {
codex_approval_kind: "mcp_tool_call",
source: "connector",
connector_id: "connector_google_calendar",
connector_name: "Google Calendar",
persist: ["session", "always"],
tool_title: "create_event",
},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "always",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({
action: "accept",
content: null,
_meta: null,
});
});
it("declines denied auto plugin app approvals", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-deny", status: "accepted" })

View File

@@ -318,13 +318,10 @@ async function buildPluginPolicyElicitationResponse(params: {
paramsForRun: params.paramsForRun,
title: approvalPrompt.title,
description: approvalPrompt.description,
allowedDecisions: allowedPluginPolicyApprovalDecisions(mode, approvalPrompt),
allowedDecisions: approvalPrompt.allowedDecisions,
signal: params.signal,
});
return buildElicitationResponse(
approvalPrompt,
oneShotPluginPolicyApprovalOutcome(mode, outcome),
);
return buildElicitationResponse(approvalPrompt, outcome);
}
logPluginElicitationDecline("unmappable_schema", params.requestParams);
return declineElicitationResponse();
@@ -332,28 +329,10 @@ async function buildPluginPolicyElicitationResponse(params: {
function resolvePluginDestructiveApprovalMode(
entry: PluginAppPolicyContextEntry,
): "allow" | "deny" | "auto" | "always" {
): "allow" | "deny" | "auto" {
return entry.destructiveApprovalMode ?? (entry.allowDestructiveActions ? "allow" : "deny");
}
function allowedPluginPolicyApprovalDecisions(
mode: "allow" | "deny" | "auto" | "always",
approvalPrompt: BridgeableApprovalElicitation,
): ExecApprovalDecision[] {
const allowedDecisions = approvalPrompt.allowedDecisions ?? ["allow-once", "deny"];
if (mode !== "always") {
return allowedDecisions;
}
return allowedDecisions.filter((decision) => decision !== "allow-always");
}
function oneShotPluginPolicyApprovalOutcome(
mode: "allow" | "deny" | "auto" | "always",
outcome: AppServerApprovalOutcome,
): AppServerApprovalOutcome {
return mode === "always" && outcome === "approved-session" ? "approved-once" : outcome;
}
function readPluginApprovalElicitation(
entry: PluginAppPolicyContextEntry,
requestParams: JsonObject,

View File

@@ -170,379 +170,6 @@ describe("Codex plugin thread config", () => {
});
});
it("exposes destructive app access while clearing only durable approval overrides for always mode", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
let configReadCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail(
"google-calendar",
[appSummary("google-calendar-app")],
["google-calendar"],
);
}
if (method === "config/read") {
configReadCount += 1;
if (configReadCount > 1) {
return {
config: {
apps: {
"google-calendar-app": {
tools: {
"calendar/read": {
enabled: false,
},
},
},
},
},
};
}
return {
config: {
apps: {
"google-calendar-app": {
tools: {
"calendar/create": {
approval_mode: "approve",
enabled: false,
},
"calendar/read": {
enabled: false,
},
"calendar/update": {
approvalMode: "approve",
},
},
},
},
},
};
}
if (method === "config/value/write") {
return {};
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request,
});
const apps = config.configPatch?.apps as Record<string, unknown> | undefined;
expect(apps?.["google-calendar-app"]).toEqual({
enabled: true,
destructive_enabled: true,
open_world_enabled: true,
default_tools_approval_mode: "auto",
});
expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({
allowDestructiveActions: true,
destructiveApprovalMode: "always",
});
expect(request).toHaveBeenCalledWith("config/read", { includeLayers: false });
expect(request.mock.calls.filter(([method]) => method === "config/read")).toHaveLength(2);
expect(request).toHaveBeenCalledWith("config/value/write", {
keyPath: 'apps."google-calendar-app".tools."calendar/create".approval_mode',
value: null,
mergeStrategy: "replace",
});
expect(request).toHaveBeenCalledWith("config/value/write", {
keyPath: 'apps."google-calendar-app".tools."calendar/update".approval_mode',
value: null,
mergeStrategy: "replace",
});
expect(request).not.toHaveBeenCalledWith("config/value/write", {
keyPath: 'apps."google-calendar-app".tools',
value: null,
mergeStrategy: "replace",
});
});
it("omits always policy apps when cwd effective approval overrides remain after cleanup", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
let configReadCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail(
"google-calendar",
[appSummary("google-calendar-app")],
["google-calendar"],
);
}
if (method === "config/read") {
configReadCount += 1;
return {
config: {
apps: {
"google-calendar-app": {
tools: {
"calendar/create": {
approval_mode: "approve",
source: configReadCount === 1 ? "user" : "project",
},
},
},
},
},
};
}
if (method === "config/value/write") {
return { status: "ok" };
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
configCwd: "/repo/project",
nowMs: 1,
request,
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(request).toHaveBeenCalledWith("config/read", {
includeLayers: false,
cwd: "/repo/project",
});
expect(request.mock.calls.filter(([method]) => method === "config/read")).toHaveLength(2);
expect(config.diagnostics).toStrictEqual([
{
code: "approval_overrides_clear_failed",
plugin: {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "always",
},
message:
"Could not clear durable Codex app approval overrides for google-calendar-app: effective approval overrides remain for calendar/create",
},
]);
});
it("omits always policy apps when approval override writes are overridden", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const request = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail(
"google-calendar",
[appSummary("google-calendar-app")],
["google-calendar"],
);
}
if (method === "config/read") {
return {
config: {
apps: {
"google-calendar-app": {
tools: {
"calendar/create": {
approval_mode: "approve",
},
},
},
},
},
};
}
if (method === "config/value/write") {
return { status: "okOverridden" };
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
configCwd: "/repo/project",
nowMs: 1,
request,
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([
{
code: "approval_overrides_clear_failed",
plugin: {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "always",
},
message:
"Could not clear durable Codex app approval overrides for google-calendar-app: approval override for calendar/create is controlled by another config layer",
},
]);
});
it("omits always policy apps when durable approval override cleanup fails", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail(
"google-calendar",
[appSummary("google-calendar-app")],
["google-calendar"],
);
}
if (method === "config/read") {
throw new Error("readonly config");
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([
{
code: "approval_overrides_clear_failed",
plugin: {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "always",
},
message:
"Could not clear durable Codex app approval overrides for google-calendar-app: readonly config",
},
]);
});
it("builds a restrictive app config when native plugin support is disabled", async () => {
expect(
shouldBuildCodexPluginThreadConfig({

View File

@@ -29,7 +29,7 @@ import {
type CodexPluginOwnedApp,
type CodexPluginRuntimeRequest,
} from "./plugin-inventory.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
import type { JsonObject, JsonValue } from "./protocol.js";
/** Policy context for one app id exposed by a configured Codex plugin. */
export type PluginAppPolicyContextEntry = {
@@ -52,7 +52,7 @@ export type PluginAppPolicyContext = {
export type CodexPluginThreadConfigDiagnostic =
| CodexPluginInventoryDiagnostic
| {
code: "plugin_activation_failed" | "app_not_ready" | "approval_overrides_clear_failed";
code: "plugin_activation_failed" | "app_not_ready";
plugin?: ResolvedCodexPluginPolicy;
message: string;
};
@@ -72,7 +72,6 @@ export type CodexPluginThreadConfig = {
export type BuildCodexPluginThreadConfigParams = {
pluginConfig?: unknown;
request: CodexPluginRuntimeRequest;
configCwd?: string;
appCache?: CodexAppInventoryCache;
appCacheKey: string;
nowMs?: number;
@@ -251,18 +250,6 @@ export async function buildCodexPluginThreadConfig(
});
continue;
}
if (
record.policy.destructiveApprovalMode === "always" &&
!(await clearPersistedAppToolApprovalOverrides({
request: params.request,
configCwd: params.configCwd,
plugin: record.policy,
app,
diagnostics,
}))
) {
continue;
}
const appConfig: JsonObject = {
enabled: true,
destructive_enabled: record.policy.allowDestructiveActions,
@@ -380,86 +367,6 @@ function buildPluginAppPolicyContext(
};
}
async function clearPersistedAppToolApprovalOverrides(params: {
request: CodexPluginRuntimeRequest;
configCwd?: string;
plugin: ResolvedCodexPluginPolicy;
app: CodexPluginOwnedApp;
diagnostics: CodexPluginThreadConfigDiagnostic[];
}): Promise<boolean> {
try {
const overrideNames = await readPersistedAppToolApprovalOverrideNames(params);
for (const toolName of overrideNames) {
const response = await params.request("config/value/write", {
keyPath: `apps.${quoteConfigKeyPathSegment(params.app.id)}.tools.${quoteConfigKeyPathSegment(
toolName,
)}.approval_mode`,
value: null,
mergeStrategy: "replace",
});
if (isOverriddenConfigWriteResponse(response)) {
throw new Error(`approval override for ${toolName} is controlled by another config layer`);
}
}
const remainingOverrideNames = await readPersistedAppToolApprovalOverrideNames(params);
if (remainingOverrideNames.length > 0) {
throw new Error(
`effective approval overrides remain for ${remainingOverrideNames.join(", ")}`,
);
}
return true;
} catch (error) {
params.diagnostics.push({
code: "approval_overrides_clear_failed",
plugin: params.plugin,
message: `Could not clear durable Codex app approval overrides for ${params.app.id}: ${
error instanceof Error ? error.message : String(error)
}`,
});
return false;
}
}
async function readPersistedAppToolApprovalOverrideNames(params: {
request: CodexPluginRuntimeRequest;
configCwd?: string;
app: CodexPluginOwnedApp;
}): Promise<string[]> {
const response = await params.request("config/read", {
includeLayers: false,
...(params.configCwd ? { cwd: params.configCwd } : {}),
});
const config = isJsonObject(response) ? response.config : undefined;
const appsRoot = isJsonObject(config) ? config.apps : undefined;
const nestedApps = isJsonObject(appsRoot) ? appsRoot.apps : undefined;
const appConfig = isJsonObject(appsRoot)
? (appsRoot[params.app.id] ??
(isJsonObject(nestedApps) ? nestedApps[params.app.id] : undefined))
: undefined;
const tools = isJsonObject(appConfig) ? appConfig.tools : undefined;
if (!isJsonObject(tools)) {
return [];
}
return Object.entries(tools)
.filter(([, value]) => hasPersistedToolApprovalOverride(value))
.map(([toolName]) => toolName)
.toSorted();
}
function hasPersistedToolApprovalOverride(value: JsonValue): boolean {
return (
isJsonObject(value) && (value.approval_mode !== undefined || value.approvalMode !== undefined)
);
}
function isOverriddenConfigWriteResponse(response: unknown): boolean {
return isJsonObject(response) && response.status === "okOverridden";
}
function quoteConfigKeyPathSegment(segment: string): string {
return `"${segment.replace(/["\\]/g, (char) => `\\${char}`)}"`;
}
function shouldWaitForInitialAppInventory(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,

View File

@@ -575,8 +575,6 @@ type CodexAppServerRequestResultMap = {
"account/read": CodexGetAccountResponse;
"app/list": CodexAppsListResponse;
"config/mcpServer/reload": JsonValue;
"config/read": JsonValue;
"config/value/write": JsonValue;
"environment/add": JsonValue;
"experimentalFeature/enablement/set": JsonValue;
"feedback/upload": JsonValue;

View File

@@ -112,44 +112,6 @@ describe("requestCodexAppServerJson sandbox guard", () => {
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
});
it("allows config value writes in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ ok: true }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
const params = {
keyPath: 'apps."google-calendar-app".tools',
value: null,
mergeStrategy: "replace",
};
await expect(
requestCodexAppServerJson({
method: "config/value/write",
requestParams: params,
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
}),
).resolves.toEqual({ ok: true });
expect(request).toHaveBeenCalledWith("config/value/write", params, { timeoutMs: 60_000 });
});
it("allows config reads in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ config: { apps: { apps: {} } } }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
const params = { includeLayers: false };
await expect(
requestCodexAppServerJson({
method: "config/read",
requestParams: params,
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
}),
).resolves.toEqual({ config: { apps: { apps: {} } } });
expect(request).toHaveBeenCalledWith("config/read", params, { timeoutMs: 60_000 });
});
it("allows sandbox-pinned thread starts in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ thread: { id: "thread-1" }, model: "gpt-5.5" }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });

View File

@@ -19,8 +19,6 @@ const DIRECT_METHOD_POLICIES = new Map<string, DirectMethodPolicy>([
["account/read", "allowed-control-plane"],
["app/list", "allowed-control-plane"],
["config/mcpServer/reload", "allowed-control-plane"],
["config/read", "allowed-control-plane"],
["config/value/write", "allowed-control-plane"],
["environment/add", "allowed-control-plane"],
["experimentalFeature/enablement/set", "allowed-control-plane"],
["feedback/upload", "allowed-control-plane"],

View File

@@ -145,35 +145,6 @@ describe("codex app-server session binding", () => {
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("round-trips always plugin app policy context destructive approval mode", async () => {
const sessionFile = path.join(tempDir, "session.json");
const pluginAppPolicyContext = {
fingerprint: "plugin-policy-always",
apps: {
"google-calendar-app": {
configKey: "google-calendar",
marketplaceName: "openai-curated" as const,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "always" as const,
mcpServerNames: ["google-calendar"],
},
},
pluginAppIds: {
"google-calendar": ["google-calendar-app"],
},
};
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-123",
cwd: tempDir,
pluginAppPolicyContext,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("normalizes v1 plugin app policy context destructive approval modes", async () => {
const sessionFile = path.join(tempDir, "session.json");
await fs.writeFile(

View File

@@ -421,9 +421,6 @@ function readDestructiveApprovalMode(
if (value === "auto") {
return bindingSchemaVersion === 1 ? "allow" : "auto";
}
if (value === "always" && bindingSchemaVersion === 2) {
return "always";
}
if (value === "on-request" && bindingSchemaVersion === 1) {
return "auto";
}

View File

@@ -5,7 +5,6 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createCodexTrajectoryRecorder,
recordCodexTrajectoryCompletion,
recordCodexTrajectoryContext,
resolveCodexTrajectoryAppendFlags,
resolveCodexTrajectoryPointerFlags,
@@ -81,9 +80,7 @@ describe("Codex trajectory recorder", () => {
expect(content).not.toContain("secret");
expect(content).not.toContain("sk-test-secret-token");
expect(content).not.toContain("sk-other-secret-token");
if (process.platform !== "win32") {
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
}
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
expect(fs.existsSync(path.join(tmpDir, "session.trajectory-path.json"))).toBe(true);
});
@@ -256,235 +253,4 @@ describe("Codex trajectory recorder", () => {
expect(parsed.data?.truncated).toBe(true);
expect(parsed.data?.reason).toBe("trajectory-event-size-limit");
});
it("preserves usage when truncating oversized model completion events", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const attempt = {
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never;
const usage = {
input: 384_954,
output: 5_624,
cacheRead: 333_824,
reasoningTokens: 2_038,
total: 724_402,
};
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt,
env: {},
});
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt,
threadId: "thread-1",
turnId: "turn-1",
timedOut: false,
result: {
aborted: false,
attemptUsage: usage,
assistantTexts: ["done"],
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
await trajectoryRecorder.flush();
const parsed = JSON.parse(
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
);
expect(parsed.type).toBe("model.completed");
expect(parsed.data).toMatchObject({
truncated: true,
reason: "trajectory-event-size-limit",
usage,
});
expect(parsed.data.messagesSnapshot).toBeUndefined();
expect(parsed.data.droppedFields).toContain("messagesSnapshot");
expect(Buffer.byteLength(JSON.stringify(parsed), "utf8")).toBeLessThanOrEqual(256 * 1024);
});
it("drops oversized preserved fields when needed to keep completion events bounded", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const attempt = {
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never;
const oversizedUsage = Object.fromEntries(
Array.from({ length: 100 }, (_value, index) => [`field-${index}`, "x".repeat(5_000)]),
);
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt,
env: {},
});
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt,
threadId: "thread-1",
turnId: "turn-1",
timedOut: false,
result: {
aborted: false,
attemptUsage: oversizedUsage,
assistantTexts: ["x".repeat(32_000)],
messagesSnapshot: [{ role: "assistant", content: "x".repeat(32_000) }],
} as never,
});
await trajectoryRecorder.flush();
const parsed = JSON.parse(
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
);
expect(parsed.data).toMatchObject({
truncated: true,
reason: "trajectory-event-size-limit",
});
expect(parsed.data.usage).toBeUndefined();
expect(parsed.data.droppedFields).toEqual(
expect.arrayContaining(["usage", "assistantTexts", "messagesSnapshot"]),
);
expect(Buffer.byteLength(JSON.stringify(parsed), "utf8")).toBeLessThanOrEqual(256 * 1024);
});
it("preserves usage on non-final oversized model completion events", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const attempt = {
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never;
const firstUsage = {
input: 384_954,
output: 5_624,
cacheRead: 333_824,
reasoningTokens: 2_038,
total: 724_402,
};
const secondUsage = { input: 12, output: 3, total: 15 };
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt,
env: {},
});
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt,
threadId: "thread-1",
turnId: "turn-1",
timedOut: false,
result: {
aborted: false,
attemptUsage: firstUsage,
assistantTexts: ["first"],
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt,
threadId: "thread-1",
turnId: "turn-2",
timedOut: false,
result: {
aborted: false,
attemptUsage: secondUsage,
assistantTexts: ["final answer"],
messagesSnapshot: [{ role: "assistant", content: "final answer" }],
} as never,
});
await trajectoryRecorder.flush();
const events = fs
.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8")
.trim()
.split(/\r?\n/u)
.map((line) => JSON.parse(line));
expect(events).toHaveLength(2);
expect(events[0].data).toMatchObject({
truncated: true,
usage: firstUsage,
});
expect(events[1].data).toMatchObject({
turnId: "turn-2",
usage: secondUsage,
assistantTexts: ["final answer"],
});
expect(events[1].data.truncated).toBeUndefined();
});
it("redacts secrets before preserving usage in truncated completion events", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const attempt = {
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never;
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt,
env: {},
});
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt,
threadId: "thread-1",
turnId: "turn-1",
timedOut: false,
result: {
aborted: false,
attemptUsage: {
total: 1,
apiKey: "sk-test-secret-token",
authorization: "Bearer sk-other-secret-token",
},
assistantTexts: ["done"],
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
await trajectoryRecorder.flush();
const parsed = JSON.parse(
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
);
const preservedUsage = JSON.stringify(parsed.data.usage);
expect(parsed.data.truncated).toBe(true);
expect(preservedUsage).toContain("redacted");
expect(preservedUsage).not.toContain("sk-test-secret-token");
expect(preservedUsage).not.toContain("sk-other-secret-token");
});
});

View File

@@ -40,7 +40,6 @@ const JWT_VALUE_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]
const COOKIE_PAIR_RE = /\b([A-Za-z][A-Za-z0-9_.-]{1,64})=([A-Za-z0-9+/._~%=-]{16,})(?=;|\s|$)/gu;
const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024;
const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024;
const TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS = ["usage", "promptCache"] as const;
type CodexTrajectoryOpenFlagConstants = Pick<
typeof nodeFs.constants,
@@ -83,57 +82,19 @@ function boundedTrajectoryLine(event: Record<string, unknown>): string | undefin
if (bytes <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
return `${line}\n`;
}
const originalData =
event.data && typeof event.data === "object" && !Array.isArray(event.data)
? (event.data as Record<string, unknown>)
: {};
const originalDataKeys = Object.keys(originalData);
const preservedDataKeys = new Set<string>();
const baseData = {
truncated: true,
originalBytes: bytes,
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
reason: "trajectory-event-size-limit",
};
const buildTruncatedLine = (includeDroppedFields: boolean): string | undefined => {
const data: Record<string, unknown> = { ...baseData };
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
if (preservedDataKeys.has(key)) {
data[key] = originalData[key];
}
}
if (includeDroppedFields) {
const droppedFields = originalDataKeys.filter((key) => !preservedDataKeys.has(key));
if (droppedFields.length > 0) {
data.droppedFields = droppedFields;
}
}
const truncated = JSON.stringify({ ...event, data });
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
return `${truncated}\n`;
}
return undefined;
};
let best = buildTruncatedLine(true) ?? buildTruncatedLine(false);
if (!best) {
return undefined;
const truncated = JSON.stringify({
...event,
data: {
truncated: true,
originalBytes: bytes,
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
reason: "trajectory-event-size-limit",
},
});
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
return `${truncated}\n`;
}
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
if (!Object.hasOwn(originalData, key)) {
continue;
}
preservedDataKeys.add(key);
const next = buildTruncatedLine(true) ?? buildTruncatedLine(false);
if (next) {
best = next;
continue;
}
preservedDataKeys.delete(key);
}
return best;
return undefined;
}
function resolveTrajectoryPointerFilePath(sessionFile: string): string {

View File

@@ -23,7 +23,7 @@ export type CodexPluginConfigEntry = {
enabled?: boolean;
marketplaceName?: string;
pluginName?: string;
allow_destructive_actions?: boolean | "auto" | "always";
allow_destructive_actions?: boolean | "auto";
};
export type CodexPluginsConfigBlock = {

View File

@@ -43,7 +43,7 @@ export type CodexPluginMigrationConfigEntry = {
configKey: string;
pluginName: string;
enabled: boolean;
allowDestructiveActions?: "auto" | "always";
allowDestructiveActions?: "auto";
};
type CodexPluginMigrationBlockSkipDetails = {
@@ -168,18 +168,15 @@ function isLegacyDestructivePolicyRepair(
);
}
function readExistingPluginAllowDestructiveActions(
function isLegacyDestructivePolicyConfigEntryRepair(
existing: unknown,
pluginName: string,
): "auto" | "always" | undefined {
): boolean {
const existingEntry = isRecord(existing) ? existing : undefined;
if (existingEntry?.pluginName !== pluginName) {
return undefined;
}
const normalized = normalizeExistingAllowDestructiveActions(
existingEntry.allow_destructive_actions,
return (
existingEntry?.allow_destructive_actions === "on-request" &&
existingEntry.pluginName === pluginName
);
return normalized === "auto" || normalized === "always" ? normalized : undefined;
}
function buildPluginItems(
@@ -206,15 +203,12 @@ function buildPluginItems(
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: plugin.pluginName,
...(() => {
const allowDestructiveActions = readExistingPluginAllowDestructiveActions(
existingPluginEntries[configKey],
plugin.pluginName,
);
return allowDestructiveActions
? { allow_destructive_actions: allowDestructiveActions }
: {};
})(),
...(isLegacyDestructivePolicyConfigEntryRepair(
existingPluginEntries[configKey],
plugin.pluginName,
)
? { allow_destructive_actions: "auto" }
: {}),
};
const conflict =
!ctx.overwrite &&
@@ -240,9 +234,8 @@ function buildPluginItems(
pluginName: plugin.pluginName,
sourceInstalled: plugin.installed === true,
sourceEnabled: plugin.enabled === true,
...(plannedEntry.allow_destructive_actions === "auto" ||
plannedEntry.allow_destructive_actions === "always"
? { allowDestructiveActions: plannedEntry.allow_destructive_actions }
...(plannedEntry.allow_destructive_actions === "auto"
? { allowDestructiveActions: "auto" }
: {}),
...(plugin.apps && plugin.apps.length > 0 && !shouldVerifyPluginApps(ctx)
? { sourceAppVerification: CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED }
@@ -317,15 +310,13 @@ export function readCodexPluginMigrationConfigEntry(
configKey,
pluginName,
enabled,
...(allowDestructiveActions === "auto" || allowDestructiveActions === "always"
? { allowDestructiveActions }
: {}),
...(allowDestructiveActions === "auto" ? { allowDestructiveActions: "auto" } : {}),
};
}
function readExistingAllowDestructiveActions(
config: MigrationProviderContext["config"],
): boolean | "auto" | "always" | undefined {
): boolean | "auto" | undefined {
const value = readMigrationConfigPath(config as Record<string, unknown>, [
...CODEX_PLUGIN_NATIVE_CONFIG_PATH,
"allow_destructive_actions",
@@ -333,16 +324,8 @@ function readExistingAllowDestructiveActions(
return normalizeExistingAllowDestructiveActions(value);
}
function normalizeExistingAllowDestructiveActions(
value: unknown,
): boolean | "auto" | "always" | undefined {
if (value === "auto" || value === "on-request") {
return "auto";
}
if (value === "always") {
return "always";
}
return asBoolean(value);
function normalizeExistingAllowDestructiveActions(value: unknown): boolean | "auto" | undefined {
return value === "auto" || value === "on-request" ? "auto" : asBoolean(value);
}
function readExistingPluginPolicyRepairs(

View File

@@ -2108,76 +2108,6 @@ describe("buildCodexMigrationProvider", () => {
});
});
it("preserves global always destructive plugin policy during migration", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {},
},
},
},
},
},
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
return appsList([]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
}),
);
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "migrated" });
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
});
});
it("records auth-required plugin installs as disabled explicit config entries", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {

View File

@@ -476,45 +476,6 @@ describe("google transport stream", () => {
expect(result.content[2]).toHaveProperty("thoughtSignature", "Y2FsbF9zaWdfMQ==");
});
it("preserves MAX_TOKENS when the partial response contains a function call", async () => {
guardedFetchMock.mockResolvedValueOnce(
buildSseResponse([
{
candidates: [
{
content: {
parts: [{ functionCall: { name: "lookup", args: { q: "hello" } } }],
},
finishReason: "MAX_TOKENS",
},
],
},
]),
);
const streamFn = createGoogleGenerativeAiTransportStreamFn();
const stream = await Promise.resolve(
streamFn(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [
{
name: "lookup",
description: "Look up a value",
parameters: { type: "object" },
},
],
} as Parameters<typeof streamFn>[1],
{ apiKey: "gemini-api-key" } as Parameters<typeof streamFn>[2],
),
);
const result = await stream.result();
expect(result.stopReason).toBe("length");
expect(result.content).toEqual([expect.objectContaining({ type: "toolCall", name: "lookup" })]);
});
it("strips redundant google provider prefixes from Gemini API model paths", async () => {
guardedFetchMock.mockResolvedValueOnce(buildSseResponse([]));

View File

@@ -1404,12 +1404,7 @@ function createGoogleTransportStreamFn(kind: CanonicalGoogleTransportApi): Strea
}
if (typeof candidate?.finishReason === "string") {
output.stopReason = mapStopReasonString(candidate.finishReason);
// MAX_TOKENS can leave a complete-looking partial call. Only a normal
// Google stop may promote parsed calls into an executable tool-use turn.
if (
output.stopReason === "stop" &&
output.content.some((block) => block.type === "toolCall")
) {
if (output.content.some((block) => block.type === "toolCall")) {
output.stopReason = "toolUse";
}
}

View File

@@ -2,10 +2,6 @@
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
@@ -17,7 +13,11 @@ const CHAT_API_BASE = "https://chat.googleapis.com/v1";
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
async function readGoogleChatJsonResponse<T>(response: Response, label: string): Promise<T> {
return readProviderJsonResponse<T>(response, label);
try {
return (await response.json()) as T;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
}
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
@@ -57,7 +57,7 @@ async function withGoogleChatResponse<T>(params: {
});
try {
if (!response.ok) {
const text = await readResponseTextLimited(response).catch(() => "");
const text = await response.text().catch(() => "");
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
}
return await handleResponse(response);

View File

@@ -1,5 +1,4 @@
// Googlechat plugin module implements auth behavior.
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { fetchWithSsrFGuard } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
@@ -18,10 +17,11 @@ const CHAT_CERTS_URL =
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
async function readGoogleChatCertsResponse(response: Response): Promise<Record<string, string>> {
return readProviderJsonResponse<Record<string, string>>(
response,
"Google Chat cert fetch failed",
);
try {
return (await response.json()) as Record<string, string>;
} catch (cause) {
throw new Error("Google Chat cert fetch failed: malformed JSON response", { cause });
}
}
// Size-capped to prevent unbounded growth in long-running deployments (#4948)

View File

@@ -568,137 +568,4 @@ describe("verifyGoogleChatRequest", () => {
});
expect(release).toHaveBeenCalledOnce();
});
describe("bounded JSON read (readProviderJsonResponse delegation)", () => {
afterEach(() => {
authTesting.resetGoogleChatAuthForTests();
mocks.fetchWithSsrFGuard.mockClear();
vi.unstubAllGlobals();
});
it("cancels oversized cert fetch JSON body via the 16 MiB provider cap", async () => {
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32;
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const oversizedJson = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
controller.close();
return;
}
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const release = vi.fn(async () => {});
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: oversizedJson,
release,
});
const result = await verifyGoogleChatRequest({
bearer: "token",
audienceType: "project-number",
audience: "123456789",
});
expect(result.ok).toBe(false);
expect(result.reason).toMatch(/JSON response exceeds 16777216 bytes/);
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
expect(release).toHaveBeenCalledOnce();
});
it("rejects oversized sendMessage JSON body via the 16 MiB provider cap", async () => {
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32;
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const oversizedJson = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
controller.close();
return;
}
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const release = vi.fn(async () => {});
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: oversizedJson,
release,
});
await expect(
sendGoogleChatMessage({
account,
space: "spaces/AAA",
text: "hello",
}),
).rejects.toThrow(/Google Chat API request failed: JSON response exceeds 16777216 bytes/);
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
it("caps non-OK sendMessage error bodies before formatting the API error", async () => {
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32;
const chunk = new TextEncoder().encode("x".repeat(ONE_MIB));
let bytesPulled = 0;
let canceled = false;
const oversizedError = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
controller.close();
return;
}
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{ status: 500, statusText: "Internal Server Error" },
);
const release = vi.fn(async () => {});
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: oversizedError,
release,
});
await expect(
sendGoogleChatMessage({
account,
space: "spaces/AAA",
text: "hello",
}),
).rejects.toThrow(/^Google Chat API 500: x+/);
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
expect(release).toHaveBeenCalledOnce();
});
});
});

View File

@@ -98,13 +98,13 @@ describe("buildAssistantMessage", () => {
expect(msg.stopReason).toBe("length");
});
it("keeps a length stop authoritative over complete-looking tool calls", () => {
it("keeps tool use authoritative over a length stop", () => {
const response = makeOllamaResponse({
done_reason: "length",
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
});
const msg = buildAssistantMessage(response, MODEL_INFO);
expect(msg.stopReason).toBe("length");
expect(msg.stopReason).toBe("toolUse");
});
});
@@ -282,32 +282,6 @@ describe("createOllamaStreamFn thinking events", () => {
expect(done.message?.stopReason).toBe("length");
});
it("preserves a native length stop when the partial response contains tool calls", async () => {
const events = await streamOllamaEvents(
[
makeOllamaResponse({
done_reason: "length",
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
}),
],
{},
{
messages: [{ role: "user", content: "test" }],
tools: [{ name: "read", description: "Read files", parameters: { type: "object" } }],
} as never,
);
const done = events.find((event) => event.type === "done") as {
reason?: string;
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
};
expect(done.reason).toBe("length");
expect(done.message?.stopReason).toBe("length");
expect(done.message?.content).toEqual([
expect.objectContaining({ type: "toolCall", name: "read" }),
]);
});
it("uses generic stream timeout for Ollama request timeout", async () => {
await streamOllamaEvents([makeOllamaResponse({ content: "ok" })], { timeoutMs: 2500 });

View File

@@ -656,15 +656,10 @@ function estimateTokensFromChars(chars: number): number {
}
function resolveOllamaStopReason(response: OllamaChatResponse) {
// Ollama's length terminal means generation hit its token limit, even when
// the partial response already contains a complete-looking tool call.
if (response.done_reason === "length") {
return "length" as const;
}
if (response.message.tool_calls?.length) {
return "toolUse" as const;
}
return "stop" as const;
return response.done_reason === "length" ? ("length" as const) : ("stop" as const);
}
function estimateOllamaPromptTokens(params: {

View File

@@ -713,100 +713,4 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
controller.end();
await consumer;
});
it("must NOT abort a live stream that keeps emitting block-boundary events between deltas", async () => {
// Regression for https://github.com/openclaw/openclaw/issues/96518:
// the idle timer must re-arm on block-boundary events (text_end,
// thinking_end, toolcall_start, toolcall_end), not only on token
// deltas. A stream that keeps producing boundary events between
// deltas is demonstrably alive and must not be aborted.
const { stream: baseStream, controller } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const idleTimeoutMs = 5_000;
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "glm-4.6" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = { role: "assistant", content: [{ type: "text", text: "x" }] };
// Provider starts producing a tool-call turn. The last *delta* arms the idle timer.
controller.emit({ type: "start", partial } as any);
controller.emit({
type: "toolcall_delta",
contentIndex: 0,
delta: "{",
partial,
} as any);
await vi.advanceTimersByTimeAsync(0);
// The model finalizes the tool call and deliberates on the next one,
// emitting real block-boundary events that prove the SSE socket is alive.
// Each gap is < idleTimeoutMs, so a liveness-aware watchdog must stay armed.
await vi.advanceTimersByTimeAsync(3_000);
controller.emit({
type: "toolcall_end",
contentIndex: 0,
toolCall: { name: "f", arguments: "{}" },
partial,
} as any);
await vi.advanceTimersByTimeAsync(3_000);
controller.emit({
type: "toolcall_start",
contentIndex: 1,
partial,
} as any);
// Advance to 5s after the last delta, but only 2s after the last
// boundary event. The idle timer should have been re-armed by the
// boundary events, so it must NOT fire yet.
await vi.advanceTimersByTimeAsync(1_000);
// The provider's completed answer arrives right after.
controller.emit({
type: "done",
reason: "stop",
message: {
...partial,
content: [{ type: "text", text: "final answer" }],
stopReason: "stop",
},
} as any);
controller.end();
await vi.advanceTimersByTimeAsync(0);
await consumer;
const hasDone = received.some((e) => e.type === "done");
const hasStalledError = received.some(
(e) => e.type === "error" && (e as any).error?.stopReason === "error",
);
expect(abortCalled).toBe(false);
expect(hasDone).toBe(true);
expect(hasStalledError).toBe(false);
});
});

View File

@@ -55,11 +55,7 @@ function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
return (
event.type === "text_delta" ||
event.type === "thinking_delta" ||
event.type === "toolcall_delta" ||
event.type === "text_end" ||
event.type === "thinking_end" ||
event.type === "toolcall_start" ||
event.type === "toolcall_end"
event.type === "toolcall_delta"
);
}

View File

@@ -957,7 +957,7 @@ describe("resolveTelegramFetch", () => {
expect(eighthDispatcher).toBe(firstDispatcher);
expect(ninthDispatcher).toBe(firstDispatcher);
expectPinnedFallbackIpDispatcher(3);
expectLoggerMessageContaining(loggerWarn, "fetch fallback: primary connection path failed");
expectLoggerMessageContaining(loggerWarn, "fetch fallback: DNS-resolved IP unreachable");
expectLoggerMessageContaining(
loggerDebug,
"fetch fallback: recovered from attempt 2 to attempt 0",
@@ -1193,31 +1193,6 @@ describe("resolveTelegramFetch", () => {
expect(undiciFetch).toHaveBeenCalledTimes(1);
});
it("does not automatically retry structured EADDRNOTAVAIL fetch failures", async () => {
const fetchError = buildFetchFallbackError("EADDRNOTAVAIL");
undiciFetch.mockRejectedValue(fetchError);
const resolved = resolveTelegramFetchOrThrow(undefined, STICKY_IPV4_FALLBACK_NETWORK);
await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow(
"fetch failed",
);
expect(undiciFetch).toHaveBeenCalledTimes(1);
});
it("preserves EADDRNOTAVAIL in forced fallback diagnostics", () => {
const transport = resolveTelegramTransport(undefined, STICKY_IPV4_FALLBACK_NETWORK);
const fetchError = buildFetchFallbackError("EADDRNOTAVAIL");
expect(transport.forceFallback?.("probe timeout/network error", fetchError)).toBe(true);
expect(transport.forceFallback?.("probe timeout/network error", fetchError)).toBe(true);
expectLoggerMessageContaining(loggerWarn, "primary connection path failed");
expectLoggerMessageContaining(loggerWarn, "codes=EADDRNOTAVAIL");
expectNoLoggerMessageContaining(loggerWarn, "DNS-resolved IP unreachable");
});
it("retries sticky fallback when the local network is down during connect", async () => {
undiciFetch
.mockRejectedValueOnce(buildFetchFallbackError("ENETDOWN"))

View File

@@ -488,10 +488,9 @@ export type TelegramTransport = {
dispatcherAttempts?: TelegramDispatcherAttempt[];
/**
* Promote this transport to its next fallback dispatcher before the next
* request. The original error, when available, is retained in diagnostics.
* Returns false when no fallback path exists.
* request. Returns false when no fallback path exists.
*/
forceFallback?: (reason: string, err?: unknown) => boolean;
forceFallback?: (reason: string) => boolean;
/**
* Release all dispatchers owned by this transport and the TCP sockets they
* hold. Safe to call multiple times; subsequent calls resolve immediately.
@@ -564,8 +563,7 @@ function createTelegramTransportAttempts(params: {
},
exportAttempt: { dispatcherPolicy: fallbackIpPolicy },
logLevel: "warn",
logMessage:
"fetch fallback: primary connection path failed; trying alternative Telegram API IP",
logMessage: "fetch fallback: DNS-resolved IP unreachable; trying alternative Telegram API IP",
});
return attempts;
@@ -866,8 +864,8 @@ export function resolveTelegramTransport(
fetch: resolvedFetch,
sourceFetch,
dispatcherAttempts: transportAttempts.map((attempt) => attempt.exportAttempt),
forceFallback: (reason: string, err?: unknown) =>
promoteStickyAttempt(stickyAttemptIndex + 1, err ?? new Error("forced fallback"), reason),
forceFallback: (reason: string) =>
promoteStickyAttempt(stickyAttemptIndex + 1, new Error("forced fallback"), reason),
close,
};
}

View File

@@ -362,7 +362,7 @@ describe("probeTelegram retry logic", () => {
const result = await probePromise;
expect(result.ok).toBe(true);
expect(localForceFallback).toHaveBeenCalledWith("probe timeout/network error", timeoutError);
expect(localForceFallback).toHaveBeenCalledWith("probe timeout/network error");
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 failed + 1 getMe success + 1 webhook
} finally {
vi.useRealTimers();

View File

@@ -162,8 +162,7 @@ export async function probeTelegram(
// On timeout or network error, promote the transport to its IPv4
// fallback dispatcher so the next retry (and all future probes
// sharing this cached transport) skip the stalled IPv6 path.
// Keep the original socket code in transport fallback diagnostics.
transport.forceFallback?.("probe timeout/network error", err);
transport.forceFallback?.("probe timeout/network error");
if (i < 2) {
const remainingAfterAttemptMs = resolveRemainingBudgetMs();
if (remainingAfterAttemptMs <= 0) {

View File

@@ -12,7 +12,7 @@ import type {
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveSessionStoreAgentIds, stateMigrations } from "./doctor-contract-api.js";
import { stateMigrations } from "./doctor-contract-api.js";
import {
createTestStorePath,
makePersistedCall,
@@ -68,42 +68,6 @@ describe("voice-call doctor state migration", () => {
await fs.rm(storePath, { recursive: true, force: true });
});
it("reports top-level and per-number session-store agents", () => {
expect(
resolveSessionStoreAgentIds({
cfg: {
plugins: {
entries: {
"voice-call": {
config: {
agentId: "Voice",
numbers: {
"+15550001111": { agentId: "Cards" },
"+15550002222": {},
},
},
},
},
},
},
}),
).toEqual(["cards", "voice"]);
expect(
resolveSessionStoreAgentIds({
cfg: {
plugins: { entries: { "@openclaw/voice-call": { config: {} } } },
},
}),
).toEqual(["main"]);
expect(
resolveSessionStoreAgentIds({
cfg: {
plugins: { entries: { "voice-call": { enabled: true } } },
},
}),
).toEqual(["main"]);
});
it("imports legacy calls.jsonl into plugin state", async () => {
const sourcePath = path.join(storePath, "calls.jsonl");
const call = makePersistedCall({

View File

@@ -2,8 +2,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import type {
PluginDoctorStateMigration,
PluginStateKeyedStore,
@@ -83,36 +81,6 @@ type PluginDoctorStateMigrationParams = Parameters<
PluginDoctorStateMigration["detectLegacyState"]
>[0];
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
/** Return Voice Call agents whose templated core session stores need migration. */
export function resolveSessionStoreAgentIds(params: { cfg: OpenClawConfig }): string[] {
const agentIds = new Set<string>();
for (const pluginId of ["voice-call", "@openclaw/voice-call"]) {
const entry = params.cfg.plugins?.entries?.[pluginId];
if (!entry) {
continue;
}
const config = entry.config === undefined ? {} : asRecord(entry.config);
if (!config) {
continue;
}
agentIds.add(normalizeAgentId(typeof config.agentId === "string" ? config.agentId : undefined));
const numbers = asRecord(config.numbers);
for (const route of Object.values(numbers ?? {})) {
const agentId = asRecord(route)?.agentId;
if (typeof agentId === "string") {
agentIds.add(normalizeAgentId(agentId));
}
}
}
return [...agentIds].toSorted();
}
/** Resolve the voice-call store path used by legacy and plugin-state call records. */
function resolveVoiceCallStorePath(params: {
config: PluginDoctorStateMigrationParams["config"];

View File

@@ -2,11 +2,9 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
VoiceCallConfigSchema,
resolveVoiceCallAgentSessionKey,
resolveTwilioAuthToken,
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKey,
resolveVoiceCallNumberRouteKeyForCall,
resolveVoiceCallSessionKey,
validateProviderConfig,
normalizeVoiceCallConfig,
@@ -298,23 +296,7 @@ describe("resolveVoiceCallConfig session routing", () => {
callId: "call-123",
phone: "+1 (555) 000-1111",
}),
).toBe("agent:main:voice:15550001111");
});
it("scopes generated voice session keys by configured agent", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
agentId: "Voice",
});
expect(
resolveVoiceCallSessionKey({
config,
callId: "CALL-123",
phone: "+1 (555) 000-1111",
}),
).toBe("agent:voice:voice:15550001111");
).toBe("voice:15550001111");
});
it("can scope voice sessions to each call", () => {
@@ -331,10 +313,10 @@ describe("resolveVoiceCallConfig session routing", () => {
callId: "call-123",
phone: "+1 (555) 000-1111",
}),
).toBe("agent:main:voice:call:call-123");
).toBe("voice:call:call-123");
});
it("scopes explicit voice session keys by configured agent", () => {
it("preserves explicit voice session keys", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
@@ -346,135 +328,9 @@ describe("resolveVoiceCallConfig session routing", () => {
config,
callId: "call-123",
phone: "+1 (555) 000-1111",
explicitSessionKey: "Meet-Room-1",
explicitSessionKey: "meet-room-1",
}),
).toBe("agent:main:meet-room-1");
});
it("scopes persisted and explicit keys at the agent session boundary", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
agentId: "Voice",
});
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "voice:call:legacy-call",
}),
).toBe("agent:voice:voice:call:legacy-call");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "meet-room-1",
}),
).toBe("agent:voice:meet-room-1");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:main:shared-room",
}),
).toBe("agent:voice:agent:main:shared-room");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:other:Matrix:Channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "Signal:Group:AbC123=",
}),
).toBe("agent:voice:signal:group:AbC123=");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:broken",
}),
).toBe("agent:voice:agent:broken");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent::broken",
}),
).toBe("agent:voice:agent::broken");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent::Matrix:Channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent::matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:other:room::part",
}),
).toBe("agent:voice:agent:other:room::part");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:voice:room::part",
}),
).toBe("agent:voice:room::part");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:voice::Matrix:Channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent:voice::matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:bad/id:room",
}),
).toBe("agent:voice:agent:bad/id:room");
});
it("canonicalizes raw and scoped main aliases with the core session config", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
agentId: "Voice",
});
for (const sessionKey of ["main", "agent:voice:main"]) {
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey,
coreSession: { mainKey: "work" },
}),
).toBe("agent:voice:work");
}
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "main",
coreSession: { scope: "global" },
}),
).toBe("global");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:main:main",
coreSession: { mainKey: "work" },
}),
).toBe("agent:voice:agent:main:main");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:main:main",
coreSession: { scope: "global" },
}),
).toBe("agent:voice:agent:main:main");
).toBe("meet-room-1");
});
it("resolves per-number inbound route overrides over global voice settings", () => {
@@ -539,35 +395,6 @@ describe("resolveVoiceCallConfig session routing", () => {
expect(effective.config).toBe(config);
expect(effective.config.inboundGreeting).toBe("Hello from global.");
});
it("uses dialed-number fallback only for inbound calls", () => {
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "inbound",
to: "+15550001111",
}),
).toBe("+15550001111");
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "outbound",
to: "+15550001111",
}),
).toBeUndefined();
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "inbound",
to: "+15550001111",
metadata: { numberRouteKey: "+15550002222" },
}),
).toBe("+15550002222");
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "outbound",
to: "+15550001111",
metadata: { numberRouteKey: "+15550002222" },
}),
).toBeUndefined();
});
});
describe("normalizeVoiceCallConfig", () => {

View File

@@ -1,16 +1,11 @@
// Voice Call helper module supports config behavior.
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES } from "openclaw/plugin-sdk/realtime-voice";
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
type SecretInput,
} from "openclaw/plugin-sdk/secret-input";
import {
canonicalizeMainSessionAlias,
type SessionScope,
} from "openclaw/plugin-sdk/session-store-runtime";
import { z } from "zod";
import { TtsConfigSchema } from "../api.js";
import { deepMergeDefined } from "./deep-merge.js";
@@ -574,22 +569,6 @@ export function resolveVoiceCallNumberRouteKey(
);
}
/** Resolve inbound-only number routing from a persisted call record. */
export function resolveVoiceCallNumberRouteKeyForCall(call: {
direction?: "inbound" | "outbound";
to?: string;
metadata?: { numberRouteKey?: unknown };
}): string | undefined {
if (call.direction !== "inbound") {
return undefined;
}
const storedRouteKey = call.metadata?.numberRouteKey;
if (typeof storedRouteKey === "string") {
return storedRouteKey;
}
return call.to;
}
export function resolveVoiceCallEffectiveConfig(
config: VoiceCallConfig,
phoneOrRouteKey: string | undefined,
@@ -716,73 +695,21 @@ export function normalizeVoiceCallConfig(config: VoiceCallConfigInput): VoiceCal
};
}
export type VoiceCallCoreSessionConfig = { mainKey?: string; scope?: SessionScope };
export function resolveVoiceCallSessionKey(params: {
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
config: Pick<VoiceCallConfig, "sessionScope">;
callId: string;
phone?: string;
explicitSessionKey?: string;
coreSession?: VoiceCallCoreSessionConfig;
}): string {
const explicit = params.explicitSessionKey?.trim();
if (explicit) {
return resolveVoiceCallAgentSessionKey({
config: params.config,
sessionKey: explicit,
coreSession: params.coreSession,
});
return explicit;
}
// Startup migration promotes unambiguous shipped `voice:*` rows;
// generate only canonical keys here so new history never needs repair.
const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
if (params.config.sessionScope === "per-call") {
return `${prefix}:call:${params.callId}`.toLowerCase();
return `voice:call:${params.callId}`;
}
const normalizedPhone = params.phone?.replace(/\D/g, "");
return (
normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`
).toLowerCase();
}
/** Resolve persisted or integration-provided keys into the configured agent namespace. */
export function resolveVoiceCallAgentSessionKey(params: {
config: Pick<VoiceCallConfig, "agentId">;
sessionKey: string;
coreSession?: VoiceCallCoreSessionConfig;
}): string {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
throw new Error("Voice Call session key cannot be empty");
}
const lower = sessionKey.toLowerCase();
const agentId = normalizeAgentId(params.config.agentId);
if (lower === "global" || lower === "unknown") {
return lower;
}
const parsedInput = parseAgentSessionKey(sessionKey);
let normalizedScopedKey: string;
if (
parsedInput &&
normalizeAgentId(parsedInput.agentId) === parsedInput.agentId &&
parsedInput.agentId === agentId
) {
normalizedScopedKey = `agent:${parsedInput.agentId}:${parsedInput.rest}`;
} else {
// Voice Call's configured agent owns both the store and runtime. Foreign or
// malformed agent-shaped input is an opaque integration key, not a route.
const wrappedInput = parseAgentSessionKey(`agent:${agentId}:${sessionKey}`);
if (!wrappedInput) {
throw new Error("Voice Call session key could not be normalized");
}
normalizedScopedKey = `agent:${agentId}:${wrappedInput.rest}`;
}
const canonicalMain = canonicalizeMainSessionAlias({
cfg: { session: params.coreSession },
agentId,
sessionKey: normalizedScopedKey,
});
return canonicalMain === normalizedScopedKey ? normalizedScopedKey : canonicalMain;
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
}
/**

View File

@@ -1,12 +1,14 @@
// Voice Call plugin module implements core bridge behavior.
import type { OpenClawPluginApi } from "../api.js";
import type { VoiceCallCoreSessionConfig, VoiceCallTtsConfig } from "./config.js";
import type { VoiceCallTtsConfig } from "./config.js";
// Narrow core runtime/config contracts consumed by the voice-call plugin.
/** Core config subset read by voice-call helpers. */
export type CoreConfig = {
session?: VoiceCallCoreSessionConfig & { store?: string };
session?: {
store?: string;
};
messages?: {
tts?: VoiceCallTtsConfig;
};

View File

@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { VoiceCallConfig, VoiceCallCoreSessionConfig } from "./config.js";
import type { VoiceCallConfig } from "./config.js";
import type { CallManagerContext, StreamSessionIssuer } from "./manager/context.js";
import { processEvent as processManagerEvent } from "./manager/events.js";
import { getCallByProviderCallId as getCallByProviderCallIdFromMaps } from "./manager/lookup.js";
@@ -82,7 +82,6 @@ export class CallManager {
private rejectedProviderCallIds = new Set<string>();
private provider: VoiceCallProvider | null = null;
private config: VoiceCallConfig;
private coreSession: VoiceCallCoreSessionConfig | undefined;
private storePath: string;
private webhookUrl: string | null = null;
private activeTurnCalls = new Set<CallId>();
@@ -104,13 +103,8 @@ export class CallManager {
*/
streamSessionIssuer: StreamSessionIssuer | undefined;
constructor(
config: VoiceCallConfig,
storePath?: string,
coreSession?: VoiceCallCoreSessionConfig,
) {
constructor(config: VoiceCallConfig, storePath?: string) {
this.config = config;
this.coreSession = coreSession;
this.storePath = resolveDefaultStoreBase(config, storePath);
}
@@ -359,7 +353,6 @@ export class CallManager {
rejectedProviderCallIds: this.rejectedProviderCallIds,
provider: this.provider,
config: this.config,
coreSession: this.coreSession,
storePath: this.storePath,
webhookUrl: this.webhookUrl,
activeTurnCalls: this.activeTurnCalls,

View File

@@ -1,5 +1,5 @@
// Voice Call plugin module implements context behavior.
import type { VoiceCallConfig, VoiceCallCoreSessionConfig } from "../config.js";
import type { VoiceCallConfig } from "../config.js";
import type { VoiceCallProvider } from "../providers/base.js";
import type { CallId, CallRecord } from "../types.js";
@@ -21,7 +21,6 @@ type CallManagerRuntimeState = {
type CallManagerRuntimeDeps = {
provider: VoiceCallProvider | null;
config: VoiceCallConfig;
coreSession?: VoiceCallCoreSessionConfig;
storePath: string;
webhookUrl: string | null;
};

View File

@@ -633,7 +633,7 @@ describe("processEvent (functional)", () => {
processEvent(ctx, event);
const call = requireFirstActiveCall(ctx);
expect(call.sessionKey).toBe(`agent:main:voice:call:${call.callId}`);
expect(call.sessionKey).toBe(`voice:call:${call.callId}`);
});
it("applies per-number inbound greeting and stores the matched route key", () => {

View File

@@ -155,12 +155,11 @@ describe("voice-call outbound helpers", () => {
fromNumber: "+14155550100",
tts: { provider: "openai", providers: { openai: { voice: "nova" } } },
},
coreSession: { mainKey: "work" },
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
};
const result = await initiateCall(ctx as never, "+14155550123", "main", {
const result = await initiateCall(ctx as never, "+14155550123", "session-1", {
mode: "notify",
message: "hello there",
});
@@ -179,7 +178,7 @@ describe("voice-call outbound helpers", () => {
inlineTwiml: "<Response />",
});
expect(ctx.providerCallIdMap.get("provider-1")).toBe(callId);
expect(ctx.activeCalls.get(callId)?.sessionKey).toBe("agent:main:work");
expect(ctx.activeCalls.get(callId)?.sessionKey).toBe("session-1");
expect(persistCallRecordMock).toHaveBeenCalledTimes(2);
});
@@ -204,9 +203,7 @@ describe("voice-call outbound helpers", () => {
expect(result.success).toBe(true);
expect(result.callId).toBeTypeOf("string");
expect(result.callId).not.toBe("");
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(
`agent:main:voice:call:${result.callId}`,
);
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(`voice:call:${result.callId}`);
});
it("initiates conversation calls with pre-connect DTMF TwiML", async () => {
@@ -407,7 +404,6 @@ describe("voice-call outbound helpers", () => {
const call = {
callId: "call-1",
providerCallId: "provider-1",
direction: "inbound",
state: "active",
to: "+15550002222",
metadata: { numberRouteKey: "+15550002222" },
@@ -442,40 +438,6 @@ describe("voice-call outbound helpers", () => {
});
});
it("keeps top-level TTS for outbound calls to a number with an inbound route", async () => {
const call = {
callId: "call-1",
providerCallId: "provider-1",
direction: "outbound",
state: "active",
to: "+15550002222",
};
const playTts = vi.fn(async () => {});
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map(),
provider: { name: "twilio", playTts },
config: {
tts: { provider: "openai", providers: { openai: { voice: "coral" } } },
numbers: {
"+15550002222": {
tts: { providers: { openai: { voice: "alloy" } } },
},
},
},
storePath: "/tmp/voice-call.json",
};
await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true });
expect(playTts).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
text: "hello",
voice: "coral",
});
});
it("sends DTMF through connected provider calls", async () => {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
const sendDtmfProvider = vi.fn(async () => {});

View File

@@ -3,7 +3,6 @@ import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKeyForCall,
resolveVoiceCallSessionKey,
type CallMode,
} from "../config.js";
@@ -35,7 +34,6 @@ type InitiateContext = Pick<
| "providerCallIdMap"
| "provider"
| "config"
| "coreSession"
| "storePath"
| "webhookUrl"
| "streamSessionIssuer"
@@ -192,7 +190,6 @@ export async function initiateCall(
callId,
phone: to,
explicitSessionKey: sessionKey,
coreSession: ctx.coreSession,
}),
startedAt: Date.now(),
transcript: [],
@@ -291,7 +288,8 @@ export async function speak(
transitionState(call, "speaking");
persistCallRecord(ctx.storePath, call);
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
const numberRouteKey =
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
const voice = resolvePreferredTtsVoice(
resolveVoiceCallEffectiveConfig(ctx.config, numberRouteKey).config,
);

View File

@@ -71,7 +71,7 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
sessionStore[params.sessionKey] = { ...params.entry };
},
);
const runEmbeddedAgent = vi.fn(async (_args: EmbeddedAgentArgs) => ({
const runEmbeddedAgent = vi.fn(async () => ({
payloads,
meta: { durationMs: 12, aborted: false },
}));
@@ -233,7 +233,7 @@ describe("generateVoiceResponse", () => {
const { runtime, runEmbeddedAgent, patchSessionEntry, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Pinned model works."}' },
]);
sessionStore["agent:main:voice:15550001111"] = {
sessionStore["voice:15550001111"] = {
sessionId: "existing-session",
updatedAt: 100,
model: "old-model",
@@ -257,7 +257,7 @@ describe("generateVoiceResponse", () => {
});
expect(result.text).toBe("Pinned model works.");
const pinnedSessionEntry = sessionStore["agent:main:voice:15550001111"];
const pinnedSessionEntry = sessionStore["voice:15550001111"];
expect(pinnedSessionEntry?.providerOverride).toBe("openai");
expect(pinnedSessionEntry?.modelOverride).toBe("gpt-4.1-nano");
expect(pinnedSessionEntry?.modelOverrideSource).toBe("auto");
@@ -271,17 +271,17 @@ describe("generateVoiceResponse", () => {
);
expect(patchSessionEntryCall[0]).toMatchObject({
storePath: "/tmp/openclaw/main/sessions.json",
sessionKey: "agent:main:voice:15550001111",
sessionKey: "voice:15550001111",
replaceEntry: true,
});
expect((patchSessionEntryCall[0] as { update?: unknown }).update).toBeTypeOf("function");
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.provider).toBe("openai");
expect(args.model).toBe("gpt-4.1-nano");
expect(args.sessionKey).toBe("agent:main:voice:15550001111");
expect(args.sessionKey).toBe("voice:15550001111");
});
it("canonicalizes a restored legacy per-call key for classic responses", async () => {
it("uses the persisted per-call session key for classic responses", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Fresh call context."}' },
]);
@@ -302,102 +302,15 @@ describe("generateVoiceResponse", () => {
});
expect(result.text).toBe("Fresh call context.");
const perCallSessionEntry = sessionStore["agent:main:voice:call:call-123"];
const perCallSessionEntry = sessionStore["voice:call:call-123"];
expect(perCallSessionEntry?.sessionId).toBeTypeOf("string");
expect(perCallSessionEntry?.sessionId).not.toBe("");
expect(sessionStore["voice:15550001111"]).toBeUndefined();
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.sessionKey).toBe("agent:main:voice:call:call-123");
expect(args.sessionKey).toBe("voice:call:call-123");
expect(args.sandboxSessionKey).toBe("agent:main:voice:call:call-123");
});
it("preserves an explicit call key while scoping its session-store identity", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Shared meeting context."}' },
]);
const voiceConfig = VoiceCallConfigSchema.parse({
agentId: "voice",
responseTimeoutMs: 5000,
});
await generateVoiceResponse({
voiceConfig,
coreConfig: {} as CoreConfig,
agentRuntime: runtime,
callId: "call-123",
sessionKey: "meet-room-1",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(sessionStore["agent:voice:meet-room-1"]?.sessionId).toBeTypeOf("string");
expect(sessionStore["meet-room-1"]).toBeUndefined();
expect(requireEmbeddedAgentArgs(runEmbeddedAgent).sessionKey).toBe("agent:voice:meet-room-1");
});
it("keeps wrapped foreign Matrix identities stable across restore", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Matrix context."}' },
]);
const voiceConfig = VoiceCallConfigSchema.parse({
agentId: "voice",
responseTimeoutMs: 5000,
});
const canonical = "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org";
const generate = (sessionKey: string) =>
generateVoiceResponse({
voiceConfig,
coreConfig: {} as CoreConfig,
agentRuntime: runtime,
callId: "call-123",
sessionKey,
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
await generate("agent:other:matrix:channel:!RoomAbC:example.org");
await generate(canonical);
await generate("agent:other:matrix:channel:!Roomabc:example.org");
expect(sessionStore[canonical]?.sessionId).toBeTypeOf("string");
expect(
sessionStore["agent:voice:agent:other:matrix:channel:!Roomabc:example.org"]?.sessionId,
).toBeTypeOf("string");
expect(Object.keys(sessionStore)).toHaveLength(2);
const sessionKeys = runEmbeddedAgent.mock.calls.map(([args]) => args.sessionKey);
expect(sessionKeys).toEqual([
canonical,
canonical,
"agent:voice:agent:other:matrix:channel:!Roomabc:example.org",
]);
});
it("uses the configured core main key for restored call aliases", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Main context."}' },
]);
const voiceConfig = VoiceCallConfigSchema.parse({
agentId: "voice",
responseTimeoutMs: 5000,
});
await generateVoiceResponse({
voiceConfig,
coreConfig: { session: { mainKey: "work" } },
agentRuntime: runtime,
callId: "call-123",
sessionKey: "agent:voice:main",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(sessionStore["agent:voice:work"]?.sessionId).toBeTypeOf("string");
expect(requireEmbeddedAgentArgs(runEmbeddedAgent).sessionKey).toBe("agent:voice:work");
});
it("uses the main agent workspace when voice config omits agentId", async () => {
const {
runtime,
@@ -424,18 +337,17 @@ describe("generateVoiceResponse", () => {
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "main");
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "main");
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "main");
const defaultSessionEntry = sessionStore["agent:main:voice:15550001111"];
const defaultSessionEntry = sessionStore["voice:15550001111"];
if (!defaultSessionEntry) {
throw new Error("Expected default voice session entry");
}
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.agentDir).toBe("/tmp/openclaw/agents/main");
expect(args.agentId).toBe("main");
expect(args.sessionKey).toBe("agent:main:voice:15550001111");
expect(args.sessionTarget).toStrictEqual({
agentId: "main",
sessionId: defaultSessionEntry.sessionId,
sessionKey: "agent:main:voice:15550001111",
sessionKey: "voice:15550001111",
storePath: "/tmp/openclaw/main/sessions.json",
});
expect(args.sandboxSessionKey).toBe("agent:main:voice:15550001111");
@@ -473,18 +385,17 @@ describe("generateVoiceResponse", () => {
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "voice");
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "voice");
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "voice");
const voiceSessionEntry = sessionStore["agent:voice:voice:15550001111"];
const voiceSessionEntry = sessionStore["voice:15550001111"];
if (!voiceSessionEntry) {
throw new Error("Expected routed voice session entry");
}
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.agentDir).toBe("/tmp/openclaw/agents/voice");
expect(args.agentId).toBe("voice");
expect(args.sessionKey).toBe("agent:voice:voice:15550001111");
expect(args.sessionTarget).toStrictEqual({
agentId: "voice",
sessionId: voiceSessionEntry.sessionId,
sessionKey: "agent:voice:voice:15550001111",
sessionKey: "voice:15550001111",
storePath: "/tmp/openclaw/voice/sessions.json",
});
expect(args.sandboxSessionKey).toBe("agent:voice:voice:15550001111");

View File

@@ -234,7 +234,6 @@ export async function generateVoiceResponse(
callId,
phone: from,
explicitSessionKey: sessionKey,
coreSession: coreConfig.session,
});
const agentId = voiceConfig.agentId ?? "main";
const toolsAllow = resolveVoiceAgentToolsAllow(cfg, agentId);

View File

@@ -29,42 +29,22 @@ const mocks = vi.hoisted(() => ({
vi.mock("./config.js", () => ({
resolveVoiceCallSessionKey: (params: {
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
config: Pick<VoiceCallConfig, "sessionScope">;
callId: string;
phone?: string;
explicitSessionKey?: string;
}) => {
const explicit = params.explicitSessionKey?.trim();
if (explicit) {
const lower = explicit.toLowerCase();
return lower === "global" || lower === "unknown" || lower.startsWith("agent:")
? explicit
: `agent:${params.config.agentId?.trim().toLowerCase() || "main"}:${explicit}`;
return explicit;
}
const agentId = params.config.agentId?.trim().toLowerCase() || "main";
const prefix = `agent:${agentId}:voice`;
if (params.config.sessionScope === "per-call") {
return `${prefix}:call:${params.callId}`.toLowerCase();
return `voice:call:${params.callId}`;
}
const normalizedPhone = params.phone?.replace(/\D/g, "");
return (
normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`
).toLowerCase();
},
resolveVoiceCallNumberRouteKeyForCall: (call: {
direction?: "inbound" | "outbound";
to?: string;
metadata?: { numberRouteKey?: unknown };
}) =>
call.direction === "inbound"
? typeof call.metadata?.numberRouteKey === "string"
? call.metadata.numberRouteKey
: call.to
: undefined,
resolveVoiceCallEffectiveConfig: (config: VoiceCallConfig, numberRouteKey?: string) => {
const route = numberRouteKey ? config.numbers[numberRouteKey] : undefined;
return route ? { config: { ...config, ...route }, numberRouteKey } : { config };
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
},
resolveVoiceCallEffectiveConfig: (config: VoiceCallConfig) => ({ config }),
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
resolveTwilioAuthToken: mocks.resolveTwilioAuthToken,
validateProviderConfig: mocks.validateProviderConfig,
@@ -398,13 +378,9 @@ describe("createVoiceCallRuntime lifecycle", () => {
await runtime.stop();
});
it("wires realtime consults and keeps outbound calls off inbound number routes", async () => {
it("wires the shared realtime agent consult tool and handler", async () => {
const config = createBaseConfig();
config.inboundPolicy = "allowlist";
config.numbers["+15550009999"] = {
agentId: "inbound-route",
responseModel: "openai/gpt-5.5",
};
config.realtime.enabled = true;
config.realtime.tools = [
{
@@ -470,7 +446,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
firstCallParam(runEmbeddedAgent.mock.calls as unknown[][], "embedded OpenClaw consult"),
"embedded OpenClaw consult params",
);
expect(consultParams.sessionKey).toBe("agent:main:voice:15550009999");
expect(consultParams.sessionKey).toBe("voice:15550009999");
expect(consultParams.spawnedBy).toBe("agent:main:discord:channel:general");
expect(consultParams.messageProvider).toBe("voice");
expect(consultParams.lane).toBe("voice");
@@ -489,7 +465,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
expect(consultParams.prompt).toContain("Caller: Also check the ETA.");
});
it("canonicalizes restored legacy per-call keys for realtime consults", async () => {
it("uses persisted per-call session keys for realtime consults", async () => {
const config = createBaseConfig();
config.inboundPolicy = "allowlist";
config.realtime.enabled = true;
@@ -537,7 +513,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
),
"per-call embedded OpenClaw consult params",
);
expect(consultParams.sessionKey).toBe("agent:main:voice:call:call-1");
expect(consultParams.sessionKey).toBe("voice:call:call-1");
});
it("answers realtime consults from fast memory context before starting the full agent", async () => {
@@ -606,7 +582,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
error: console.error,
debug: console.debug,
},
sessionKey: "agent:main:voice:15550001234",
sessionKey: "voice:15550001234",
});
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});

View File

@@ -13,7 +13,6 @@ import {
import type { VoiceCallConfig } from "./config.js";
import {
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKeyForCall,
resolveVoiceCallSessionKey,
resolveTwilioAuthToken,
resolveVoiceCallConfig,
@@ -112,19 +111,20 @@ function loadRealtimeHandler(): Promise<RealtimeHandlerModule> {
function resolveVoiceCallConsultSessionKey(call: {
config: VoiceCallConfig;
coreSession?: OpenClawConfig["session"];
sessionKey?: string;
from?: string;
to?: string;
direction?: "inbound" | "outbound";
callId: string;
}): string {
if (call.sessionKey) {
return call.sessionKey;
}
const phone = call.direction === "outbound" ? call.to : call.from;
return resolveVoiceCallSessionKey({
config: call.config,
callId: call.callId,
phone: call.direction === "outbound" ? call.to : call.from,
explicitSessionKey: call.sessionKey,
coreSession: call.coreSession,
phone,
});
}
@@ -309,7 +309,7 @@ export async function createVoiceCallRuntime(params: {
if (stateRuntime) {
setVoiceCallStateRuntime({ state: stateRuntime });
}
const manager = new CallManager(config, undefined, cfg.session);
const manager = new CallManager(config);
const realtimeProvider = config.realtime.enabled
? await resolveRealtimeProvider({
config,
@@ -358,13 +358,15 @@ export async function createVoiceCallRuntime(params: {
if (!call) {
return { error: `Call "${callId}" not found` };
}
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
const numberRouteKey =
typeof call.metadata?.numberRouteKey === "string"
? call.metadata.numberRouteKey
: call.to;
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
const agentId = effectiveConfig.agentId ?? "main";
const sessionKey = resolveVoiceCallConsultSessionKey({
...call,
config: effectiveConfig,
coreSession: cfg.session,
});
const requesterSessionKey =
typeof call.metadata?.requesterSessionKey === "string"

View File

@@ -31,7 +31,6 @@ const mocks = vi.hoisted(() => {
};
return {
generateVoiceResponse: vi.fn(async () => ({ text: null })),
getRealtimeTranscriptionProvider: vi.fn<(...args: unknown[]) => unknown>(
() => realtimeTranscriptionProvider,
),
@@ -44,10 +43,6 @@ vi.mock("./realtime-transcription.runtime.js", () => ({
listRealtimeTranscriptionProviders: mocks.listRealtimeTranscriptionProviders,
}));
vi.mock("./response-generator.js", () => ({
generateVoiceResponse: mocks.generateVoiceResponse,
}));
const provider: VoiceCallProvider = {
name: "mock",
verifyWebhook: () => ({ ok: true, verifiedRequestKey: "mock:req:base" }),
@@ -1651,46 +1646,6 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => {
});
});
describe("VoiceCallWebhookServer classic response routing", () => {
it("keeps outbound calls on the top-level agent when the dialed number has an inbound route", async () => {
const call = createCall(Date.now());
call.direction = "outbound";
call.to = "+15550001111";
call.sessionKey = "agent:top:voice:15550001111";
const manager = {
getCall: (callId: string) => (callId === call.callId ? call : undefined),
speak: vi.fn(async () => ({ success: true })),
} as unknown as CallManager;
const config = createConfig({
agentId: "top",
numbers: {
"+15550001111": { agentId: "inbound-route" },
},
});
const server = new VoiceCallWebhookServer(
config,
manager,
provider,
{} as never,
undefined,
{} as never,
);
mocks.generateVoiceResponse.mockReset().mockResolvedValue({ text: null });
await (
server as unknown as {
handleInboundResponse: (callId: string, message: string) => Promise<void>;
}
).handleInboundResponse(call.callId, "hello");
const params = requireFirstMockCall(
mocks.generateVoiceResponse.mock.calls,
"classic voice response",
)[0] as { voiceConfig?: VoiceCallConfig } | undefined;
expect(params?.voiceConfig?.agentId).toBe("top");
});
});
describe("VoiceCallWebhookServer response normalization", () => {
it("preserves explicit empty provider response bodies", async () => {
const responseProvider: VoiceCallProvider = {

View File

@@ -25,7 +25,6 @@ import { isAllowlistedCaller, normalizePhoneNumber } from "./allowlist.js";
import {
normalizeVoiceCallConfig,
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKeyForCall,
type VoiceCallConfig,
} from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
@@ -1032,7 +1031,8 @@ export class VoiceCallWebhookServer {
try {
const { generateVoiceResponse } = await loadResponseGeneratorModule();
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
const numberRouteKey =
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
const effectiveConfig = resolveVoiceCallEffectiveConfig(this.config, numberRouteKey).config;
const result = await generateVoiceResponse({

View File

@@ -1695,7 +1695,6 @@
"plugin-sdk:surface:check": "node --max-old-space-size=8192 scripts/plugin-sdk-surface-report.mjs --check",
"plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs",
"plugin-sdk:usage": "node --max-old-space-size=8192 --import tsx scripts/analyze-plugin-sdk-usage.ts",
"policy:config-coverage": "node --import tsx scripts/check-policy-config-coverage.ts",
"plugins:boundary-report": "node --import tsx scripts/plugin-boundary-report.ts",
"plugins:boundary-report:ci": "node --import tsx scripts/plugin-boundary-report.ts --summary --fail-on-cross-owner --fail-on-unclassified-unused-reserved --fail-on-eligible-compat",
"plugins:boundary-report:json": "node --import tsx scripts/plugin-boundary-report.ts --json",
@@ -1778,7 +1777,6 @@
"test:docker:crestodian-first-run": "bash scripts/e2e/crestodian-first-run-docker.sh",
"test:docker:crestodian-planner": "bash scripts/e2e/crestodian-planner-docker.sh",
"test:docker:crestodian-rescue": "bash scripts/e2e/crestodian-rescue-docker.sh",
"test:docker:cron-cli": "bash scripts/e2e/cron-cli-docker.sh",
"test:docker:cron-mcp-cleanup": "bash scripts/e2e/cron-mcp-cleanup-docker.sh",
"test:docker:codex-media-path": "bash scripts/e2e/codex-media-path-docker.sh",
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
@@ -1953,6 +1951,8 @@
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
"ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts",
"ui:i18n:sync": "node --import tsx scripts/control-ui-i18n.ts sync --write",
"native:i18n:check": "node --import tsx scripts/native-app-i18n.ts check",
"native:i18n:sync": "node --import tsx scripts/native-app-i18n.ts sync --write",
"ui:install": "node scripts/ui.js install",
"verify": "node scripts/verify.mjs"
},

View File

@@ -164,126 +164,6 @@ describe("agentLoop streaming updates", () => {
expect(update.assistantMessageEvent).not.toHaveProperty("partial");
}
});
it("does not execute tool calls from a max-token-truncated assistant turn", async () => {
const execute = vi.fn(
async (): Promise<AgentToolResult<unknown>> => ({
content: [{ type: "text", text: "should not run" }],
details: {},
}),
);
const contexts: Context[] = [];
let streamCalls = 0;
const streamFn: StreamFn = async (_model, context) => {
contexts.push(context);
streamCalls += 1;
const stream = createAssistantMessageEventStream();
if (streamCalls > 1) {
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "continued" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: TEST_USAGE,
stopReason: "stop",
timestamp: 2,
};
queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message });
});
return stream;
}
const toolCall = {
type: "toolCall" as const,
id: "call-truncated-spawn",
name: "sessions_spawn",
arguments: {},
};
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "spawning" }, toolCall],
api: model.api,
provider: model.provider,
model: model.id,
usage: TEST_USAGE,
stopReason: "length",
timestamp: 1,
};
queueMicrotask(() => {
stream.push({ type: "start", partial: { ...message, content: [] } });
stream.push({ type: "toolcall_start", contentIndex: 1, partial: message });
stream.push({
type: "toolcall_end",
contentIndex: 1,
toolCall,
partial: message,
});
stream.push({ type: "done", reason: "length", message });
});
return stream;
};
const stream = agentLoop(
[{ role: "user", content: "spawn specialists", timestamp: 1 }],
{
systemPrompt: "",
messages: [],
tools: [
{
name: "sessions_spawn",
label: "sessions_spawn",
description: "Spawn a child session",
parameters: Type.Object({}, { additionalProperties: false }),
execute,
},
],
},
{
...config,
getFollowUpMessages: async () =>
streamCalls === 1 ? [{ role: "user", content: "continue", timestamp: 2 }] : [],
},
undefined,
streamFn,
);
const events = await collectEvents(stream);
const messages = await stream.result();
const truncatedMessageEnd = events.find(
(event): event is Extract<AgentEvent, { type: "message_end" }> =>
event.type === "message_end" &&
event.message.role === "assistant" &&
event.message.stopReason === "length",
);
const replayedTruncatedMessage = contexts[1]?.messages[1];
if (!truncatedMessageEnd || !replayedTruncatedMessage) {
throw new Error("expected the truncated assistant message to be emitted and replayed");
}
expect(execute).not.toHaveBeenCalled();
expect(events.some((event) => event.type === "tool_execution_start")).toBe(false);
expect(messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"user",
"assistant",
]);
expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "length" });
expect(messages[1]).not.toMatchObject({
content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]),
});
expect(truncatedMessageEnd.message).not.toMatchObject({
content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]),
});
expect(replayedTruncatedMessage).toMatchObject({ role: "assistant", stopReason: "length" });
expect(replayedTruncatedMessage).not.toMatchObject({
content: expect.arrayContaining([expect.objectContaining({ type: "toolCall" })]),
});
});
});
describe("runAgentLoop deferred tool hydration", () => {
@@ -1056,7 +936,7 @@ describe("agentLoop thinking state", () => {
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop",
stopReason: "stop",
timestamp: 1,
};
}
@@ -1088,7 +968,7 @@ describe("agentLoop thinking state", () => {
: [{ type: "text", text: "done" }];
stream.push({
type: "done",
reason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop",
reason: "stop",
message: makeAssistantMessage(activeModel, content),
});
stream.end();

View File

@@ -80,14 +80,6 @@ function resolveAssistantMessageUpdate(
return currentMessage;
}
function removeNonExecutableToolCalls(message: AssistantMessage): AssistantMessage {
if (message.stopReason === "toolUse") {
return message;
}
const content = message.content.filter((item) => item.type !== "toolCall");
return content.length === message.content.length ? message : { ...message, content };
}
/**
* Start an agent loop with a new prompt message.
* The prompt is added to the context and events are emitted for it.
@@ -350,12 +342,12 @@ async function runLoop(
return;
}
// Only completed toolUse turns dispatch; length/stop can carry partial stream blocks.
// Check for tool calls
const toolCalls = message.content.filter((c) => c.type === "toolCall");
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (message.stopReason === "toolUse" && toolCalls.length > 0) {
if (toolCalls.length > 0) {
const executedToolBatch = await executeToolCalls(
currentContext,
message,
@@ -516,7 +508,7 @@ async function streamAssistantResponse(
case "done":
case "error": {
const finalMessage = removeNonExecutableToolCalls(await response.result());
const finalMessage = await response.result();
if (addedPartial) {
context.messages[context.messages.length - 1] = finalMessage;
} else {
@@ -531,7 +523,7 @@ async function streamAssistantResponse(
}
}
const finalMessage = removeNonExecutableToolCalls(await response.result());
const finalMessage = await response.result();
if (addedPartial) {
context.messages[context.messages.length - 1] = finalMessage;
} else {

View File

@@ -328,7 +328,6 @@ type SelectedConnectAuth = {
authDeviceToken?: string;
authPassword?: string;
authApprovalRuntimeToken?: string;
authAgentRuntimeIdentityToken?: string;
signatureToken?: string;
resolvedDeviceToken?: string;
storedToken?: string;
@@ -344,7 +343,6 @@ type StoredDeviceAuth = {
type AssembledConnect = {
params: ConnectParams;
authApprovalRuntimeToken: string | undefined;
authAgentRuntimeIdentityToken: string | undefined;
resolvedDeviceToken: string | undefined;
storedToken: string | undefined;
usingStoredDeviceToken: boolean | undefined;
@@ -432,7 +430,6 @@ export type GatewayClientOptions = {
deviceToken?: string;
password?: string;
approvalRuntimeToken?: string;
agentRuntimeIdentityToken?: string;
instanceId?: string;
clientName?: GatewayClientName;
clientDisplayName?: string;
@@ -972,24 +969,6 @@ export class GatewayClient {
this.ws?.close(1013, "gateway starting");
return;
}
if (
this.shouldFailClosedForUnsupportedAgentRuntimeIdentity({
error: err,
authAgentRuntimeIdentityToken: assembled.authAgentRuntimeIdentityToken,
})
) {
const unsupportedIdentityError = new Error(
"gateway rejected required agent runtime identity auth field; refusing to retry without it",
);
this.notifyConnectError(unsupportedIdentityError);
this.logError(`gateway connect failed: ${unsupportedIdentityError.message}`);
// This identity scopes model-mediated cron calls. Retrying without it
// would turn an old/new mismatch into an unscoped operator call.
this.closed = true;
this.clearReconnectTimer();
this.ws?.close(1008, "connect failed");
return;
}
if (
this.shouldRetryWithoutApprovalRuntimeToken({
error: err,
@@ -1025,7 +1004,6 @@ export class GatewayClient {
authDeviceToken,
authPassword,
authApprovalRuntimeToken,
authAgentRuntimeIdentityToken,
signatureToken,
resolvedDeviceToken,
storedToken,
@@ -1042,15 +1020,13 @@ export class GatewayClient {
authBootstrapToken ||
authPassword ||
resolvedDeviceToken ||
authApprovalRuntimeToken ||
authAgentRuntimeIdentityToken
authApprovalRuntimeToken
? {
token: authToken,
bootstrapToken: authBootstrapToken,
deviceToken: authDeviceToken ?? resolvedDeviceToken,
password: authPassword,
approvalRuntimeToken: authApprovalRuntimeToken,
agentRuntimeIdentityToken: authAgentRuntimeIdentityToken,
}
: undefined;
const signedAtMs = Date.now();
@@ -1093,7 +1069,6 @@ export class GatewayClient {
}),
},
authApprovalRuntimeToken,
authAgentRuntimeIdentityToken,
resolvedDeviceToken,
storedToken,
usingStoredDeviceToken,
@@ -1319,25 +1294,6 @@ export class GatewayClient {
return message.includes("invalid connect params") && message.includes("approvalruntimetoken");
}
private shouldFailClosedForUnsupportedAgentRuntimeIdentity(params: {
error: unknown;
authAgentRuntimeIdentityToken?: string;
}): boolean {
if (!params.authAgentRuntimeIdentityToken) {
return false;
}
if (!(params.error instanceof GatewayClientRequestError)) {
return false;
}
if (params.error.gatewayCode !== "INVALID_REQUEST") {
return false;
}
const message = normalizeLowercaseStringOrEmpty(params.error.message);
return (
message.includes("invalid connect params") && message.includes("agentruntimeidentitytoken")
);
}
private isTrustedDeviceRetryEndpoint(): boolean {
const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789";
try {
@@ -1365,9 +1321,6 @@ export class GatewayClient {
const authApprovalRuntimeToken = this.approvalRuntimeTokenCompatibilityDisabled
? undefined
: normalizeOptionalString(this.opts.approvalRuntimeToken);
const authAgentRuntimeIdentityToken = normalizeOptionalString(
this.opts.agentRuntimeIdentityToken,
);
const storedAuth = this.loadStoredDeviceAuth(role);
const storedToken = storedAuth?.token ?? null;
const storedScopes = storedAuth?.scopes;
@@ -1401,7 +1354,6 @@ export class GatewayClient {
authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined,
authPassword,
authApprovalRuntimeToken,
authAgentRuntimeIdentityToken,
signatureToken: authToken ?? authBootstrapToken ?? undefined,
resolvedDeviceToken,
storedToken: storedToken ?? undefined,

View File

@@ -26,36 +26,11 @@ const minimalAddParams = {
payload: { kind: "systemEvent", text: "tick" },
} as const;
const agentToolCallerScope = {
kind: "agentTool",
agentId: "ops",
} as const;
describe("cron protocol validators", () => {
it("accepts minimal add params", () => {
expect(validateCronAddParams(minimalAddParams)).toBe(true);
});
it("rejects public caller scope on cron admin params", () => {
expect(validateCronListParams({ callerScope: agentToolCallerScope })).toBe(false);
expect(validateCronGetParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);
expect(validateCronAddParams({ ...minimalAddParams, callerScope: agentToolCallerScope })).toBe(
false,
);
expect(
validateCronUpdateParams({
id: "job-1",
patch: { enabled: false },
callerScope: agentToolCallerScope,
}),
).toBe(false);
expect(validateCronRemoveParams({ jobId: "job-1", callerScope: agentToolCallerScope })).toBe(
false,
);
expect(validateCronRunParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);
expect(validateCronRunsParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);
});
it("accepts current and custom session targets", () => {
expect(
validateCronAddParams({

View File

@@ -70,7 +70,6 @@ export const ConnectParamsSchema = Type.Object(
deviceToken: Type.Optional(Type.String()),
password: Type.Optional(Type.String()),
approvalRuntimeToken: Type.Optional(Type.String()),
agentRuntimeIdentityToken: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),

View File

@@ -1,232 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import JSON5 from "json5";
import {
renderConfigDocBaselineArtifacts,
type ConfigDocBaselineEntry,
} from "../src/config/doc-baseline.js";
type ClassificationStatus = "observed" | "ignored" | "out-of-scope" | "deferred";
type CoverageClassification = {
readonly pattern: string;
readonly status: ClassificationStatus;
readonly area: string;
readonly policy?: string;
readonly reason: string;
readonly allowNoSchemaPath?: boolean;
};
type CoverageConfig = {
readonly monitored: readonly string[];
readonly classifications: readonly CoverageClassification[];
};
type ConfigDocBaseline = {
readonly coreEntries: readonly ConfigDocBaselineEntry[];
readonly channelEntries: readonly ConfigDocBaselineEntry[];
readonly pluginEntries: readonly ConfigDocBaselineEntry[];
};
function flattenConfigDocBaselineEntries(
baseline: ConfigDocBaseline,
): readonly ConfigDocBaselineEntry[] {
return [...baseline.coreEntries, ...baseline.channelEntries, ...baseline.pluginEntries];
}
type ClassifiedEntry = {
readonly path: string;
readonly kind: ConfigDocBaselineEntry["kind"];
readonly classification?: CoverageClassification;
};
type UnmatchedMonitoredPattern = {
readonly pattern: string;
};
const args = new Set(process.argv.slice(2));
const json = args.has("--json");
const check = args.has("--check");
const showCovered = args.has("--show-covered");
if (args.has("--help")) {
console.log(`Usage: pnpm policy:config-coverage [--check] [--json] [--show-covered]
Internal maintainer report for Policy config coverage.
Default mode is report-only and exits 0 even when paths are unclassified.
Use --check when a policy maintainer intentionally wants unclassified or stale
coverage entries to fail locally.`);
process.exit(0);
}
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const configPath = path.join(repoRoot, "scripts/lib/policy-config-coverage.jsonc");
const config = JSON5.parse(await fs.readFile(configPath, "utf8")) as CoverageConfig;
const { baseline } = await renderConfigDocBaselineArtifacts();
const monitoredEntries = flattenConfigDocBaselineEntries(baseline)
.filter((entry) => !entry.hasChildren)
.filter((entry) => matchesAny(config.monitored, entry.path))
.toSorted((left, right) => left.path.localeCompare(right.path));
const leafEntries = flattenConfigDocBaselineEntries(baseline).filter((entry) => !entry.hasChildren);
const unmatchedMonitored = config.monitored
.filter(
(pattern) =>
!leafEntries.some((entry) => pathMatchesPattern(pattern, entry.path)) &&
!config.classifications.some(
(item) => item.allowNoSchemaPath === true && pathMatchesPattern(item.pattern, pattern),
),
)
.map((pattern) => ({ pattern }))
.toSorted((left, right) => left.pattern.localeCompare(right.pattern));
const classified: ClassifiedEntry[] = monitoredEntries.map((entry) => ({
path: entry.path,
kind: entry.kind,
classification: config.classifications.find((item) =>
pathMatchesPattern(item.pattern, entry.path),
),
}));
const unclassified = classified.filter((entry) => entry.classification === undefined);
const stale = config.classifications.filter(
(item) =>
item.allowNoSchemaPath !== true &&
!monitoredEntries.some((entry) => pathMatchesPattern(item.pattern, entry.path)),
);
const summaryCounts = summarize(classified);
if (json) {
console.log(
JSON.stringify(
{
ok: unclassified.length === 0 && stale.length === 0 && unmatchedMonitored.length === 0,
monitoredPaths: monitoredEntries.length,
counts: summaryCounts,
unclassified,
unmatchedMonitored,
stale,
},
null,
2,
),
);
} else {
printTextReport({
monitoredPaths: monitoredEntries.length,
counts: summaryCounts,
unclassified,
unmatchedMonitored,
stale,
classified,
});
}
if (check && (unclassified.length > 0 || stale.length > 0 || unmatchedMonitored.length > 0)) {
process.exit(1);
}
function printTextReport(input: {
readonly monitoredPaths: number;
readonly counts: Record<string, number>;
readonly unclassified: readonly ClassifiedEntry[];
readonly unmatchedMonitored: readonly UnmatchedMonitoredPattern[];
readonly stale: readonly CoverageClassification[];
readonly classified: readonly ClassifiedEntry[];
}): void {
console.log(`Policy config coverage: ${input.monitoredPaths} monitored config leaf paths`);
for (const [key, count] of Object.entries(input.counts).toSorted(([a], [b]) =>
a.localeCompare(b),
)) {
console.log(` ${key}: ${count}`);
}
if (input.unclassified.length > 0) {
console.log("\nUnclassified config paths:");
for (const entry of input.unclassified) {
console.log(` - ${entry.path} (${entry.kind})`);
}
console.log(
"\nClassify each as observed, ignored, out-of-scope, or deferred in scripts/lib/policy-config-coverage.jsonc.",
);
} else {
console.log("\nNo unclassified monitored config paths.");
}
if (input.unmatchedMonitored.length > 0) {
console.log("\nMonitored patterns with no matching config paths:");
for (const entry of input.unmatchedMonitored) {
console.log(` - ${entry.pattern}`);
}
} else {
console.log("\nNo monitored patterns without matching config paths.");
}
if (input.stale.length > 0) {
console.log("\nStale coverage classifications:");
for (const entry of input.stale) {
console.log(` - ${entry.pattern} (${entry.area}, ${entry.status})`);
}
}
if (showCovered) {
console.log("\nCovered paths:");
for (const entry of input.classified) {
const classification = entry.classification;
console.log(
` - ${entry.path}: ${classification?.area ?? "unclassified"} / ${
classification?.status ?? "unclassified"
}`,
);
}
}
}
function summarize(entries: readonly ClassifiedEntry[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const entry of entries) {
const key =
entry.classification === undefined
? "unclassified"
: `${entry.classification.area}.${entry.classification.status}`;
counts[key] = (counts[key] ?? 0) + 1;
}
return counts;
}
function matchesAny(patterns: readonly string[], value: string): boolean {
return patterns.some((pattern) => pathMatchesPattern(pattern, value));
}
function pathMatchesPattern(pattern: string, value: string): boolean {
const patternParts = pattern.split(".");
const valueParts = value.split(".");
return matchesParts(patternParts, valueParts);
}
function matchesParts(patternParts: readonly string[], valueParts: readonly string[]): boolean {
if (patternParts.length === 0) {
return valueParts.length === 0;
}
const [head, ...tail] = patternParts;
if (head === "**") {
if (tail.length === 0) {
return true;
}
for (let index = 0; index <= valueParts.length; index += 1) {
if (matchesParts(tail, valueParts.slice(index))) {
return true;
}
}
return false;
}
if (valueParts.length === 0) {
return false;
}
if (head !== "*" && head !== valueParts[0]) {
return false;
}
return matchesParts(tail, valueParts.slice(1));
}

View File

@@ -595,6 +595,8 @@ function buildSystemPrompt(targetLocale: string, glossary: readonly GlossaryEntr
"- The JSON must be an object whose keys exactly match the provided ids.",
"- Translate all English prose; keep code, URLs, product names, CLI commands, config keys, and env vars in English.",
"- Preserve placeholders exactly, including {count}, {time}, {shown}, {total}, and similar tokens.",
"- Preserve Swift interpolation expressions such as \\(name) exactly, including the backslash and parentheses.",
"- Preserve Kotlin interpolation expressions such as $name and ${value} exactly.",
"- Preserve punctuation, ellipses, arrows, and casing when they are part of literal UI text.",
"- Preserve Markdown, inline code, HTML tags, and slash commands when present.",
"- Use fluent, neutral product UI language.",
@@ -1484,6 +1486,63 @@ async function translateBatch(
throw lastError ?? new Error("translation failed");
}
export type NativeTranslationEntry = {
id: string;
source: string;
sourcePath: string;
};
export async function translateNativeEntries(
entries: readonly NativeTranslationEntry[],
targetLocale: string,
glossary: readonly GlossaryEntry[] = [],
): Promise<Map<string, string>> {
if (!hasTranslationProvider()) {
throw new Error("native app translation requires OPENAI_API_KEY or ANTHROPIC_API_KEY");
}
const pending = entries.map((entry) => ({
cacheKey: cacheKey(entry.id, hashText(entry.source), targetLocale),
key: entry.id,
text: entry.source,
textHash: hashText(entry.source),
}));
const batches = buildTranslationBatches(pending);
let client: TranslationClient | null = null;
const clientAccess: ClientAccess = {
async getClient() {
if (!client) {
client = await TranslationClient.create(buildSystemPrompt(targetLocale, glossary));
}
return client;
},
async resetClient() {
if (!client) {
return;
}
await client.close();
client = null;
},
};
try {
const translated = new Map<string, string>();
for (const [batchIndex, batch] of batches.entries()) {
const result = await translateBatch(clientAccess, batch, {
locale: targetLocale,
localeCount: 1,
localeIndex: 1,
batchCount: batches.length,
batchIndex: batchIndex + 1,
});
for (const [id, value] of result) {
translated.set(id, value);
}
}
return translated;
} finally {
await clientAccess.resetClient();
}
}
type SyncOutcome = {
changed: boolean;
fallbackCount: number;

View File

@@ -1,251 +0,0 @@
#!/usr/bin/env bash
# Starts a packaged Gateway in Docker and verifies public cron CLI CRUD/run flows.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-cron-cli-e2e" OPENCLAW_IMAGE)"
PORT="18789"
TOKEN="cron-cli-e2e-$(date +%s)-$$"
CONTAINER_NAME="openclaw-cron-cli-e2e-$$"
CLIENT_LOG="$(mktemp -t openclaw-cron-cli-log.XXXXXX)"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$CLIENT_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" cron-cli
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 cron-cli empty)"
echo "Running in-container Gateway + cron CLI smoke..."
set +e
docker_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
-e "OPENCLAW_SKIP_CHANNELS=1" \
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
-e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \
-e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
-e "GW_TOKEN=$TOKEN" \
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
-i \
"$IMAGE_NAME" \
bash -s >"$CLIENT_LOG" 2>&1 <<'INNER'
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
entry="$(openclaw_e2e_resolve_entrypoint)"
gateway_pid=
cleanup_inner() {
openclaw_e2e_stop_process "${gateway_pid:-}"
}
dump_logs_on_error() {
status=$?
if [ "$status" -ne 0 ]; then
openclaw_e2e_dump_logs \
/tmp/cron-cli-gateway.log \
/tmp/cron-cli-device-seed.json \
/tmp/cron-cli-status.json \
/tmp/cron-cli-add.json \
/tmp/cron-cli-list.json \
/tmp/cron-cli-show.json \
/tmp/cron-cli-disable.json \
/tmp/cron-cli-enable.json \
/tmp/cron-cli-run.json \
/tmp/cron-cli-runs.json \
/tmp/cron-cli-remove.json
fi
cleanup_inner
exit "$status"
}
trap cleanup_inner EXIT
trap dump_logs_on_error ERR
cron_cli() {
node "$entry" cron "$@" --token "${GW_TOKEN:?missing GW_TOKEN}"
}
seed_paired_cli_device() {
node --input-type=module <<'NODE'
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
async function importDistChunk(prefix, marker) {
const distDir = join(process.cwd(), "dist");
const entries = await readdir(distDir);
for (const entry of entries) {
if (!entry.startsWith(prefix) || !entry.endsWith(".js")) {
continue;
}
const fullPath = join(distDir, entry);
if ((await readFile(fullPath, "utf8")).includes(marker)) {
return await import(pathToFileURL(fullPath).href);
}
}
throw new Error(`missing dist chunk ${prefix} containing ${marker}`);
}
const identityModule = await importDistChunk("device-identity-", "loadOrCreateDeviceIdentity");
const pairingModule = await importDistChunk("device-pairing-", "requestDevicePairing");
const loadOrCreateDeviceIdentity =
identityModule.loadOrCreateDeviceIdentity ?? identityModule.r;
const publicKeyRawBase64UrlFromPem =
identityModule.publicKeyRawBase64UrlFromPem ?? identityModule.a;
const approveDevicePairing = pairingModule.approveDevicePairing ?? pairingModule.n;
const getPairedDevice = pairingModule.getPairedDevice ?? pairingModule.a;
const requestDevicePairing = pairingModule.requestDevicePairing ?? pairingModule.m;
if (
typeof loadOrCreateDeviceIdentity !== "function" ||
typeof publicKeyRawBase64UrlFromPem !== "function" ||
typeof approveDevicePairing !== "function" ||
typeof getPairedDevice !== "function" ||
typeof requestDevicePairing !== "function"
) {
throw new Error("missing device pairing exports in dist chunks");
}
const identity = loadOrCreateDeviceIdentity();
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
const requiredScopes = ["operator.admin"];
const paired = await getPairedDevice(identity.deviceId);
const pairedScopes = Array.isArray(paired?.approvedScopes)
? paired.approvedScopes
: Array.isArray(paired?.scopes)
? paired.scopes
: [];
if (paired?.publicKey !== publicKey || !requiredScopes.every((scope) => pairedScopes.includes(scope))) {
const pairing = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey,
displayName: "cron cli docker smoke",
platform: process.platform,
clientId: "cli",
clientMode: "cli",
role: "operator",
scopes: requiredScopes,
silent: true,
});
const approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: requiredScopes,
});
if (approved?.status !== "approved") {
throw new Error(`failed to seed paired CLI device: ${approved?.status ?? "missing-result"}`);
}
}
process.stdout.write(JSON.stringify({ ok: true, deviceId: identity.deviceId }) + "\n");
NODE
}
read_json_field() {
local file="$1"
local field="$2"
node --input-type=module -e '
const fs = await import("node:fs/promises");
const [file, field] = process.argv.slice(1);
const value = JSON.parse(await fs.readFile(file, "utf8"))[field];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`missing string field ${field} in ${file}`);
}
process.stdout.write(value);
' "$file" "$field"
}
seed_paired_cli_device > /tmp/cron-cli-device-seed.json
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
cron_cli status --json > /tmp/cron-cli-status.json
cron_add_args=(
"cli cron smoke"
--every 1m
--command "printf openclaw-cli-cron-ok"
--no-deliver
--timeout-seconds 15
--json
)
cron_cli add "${cron_add_args[@]}" > /tmp/cron-cli-add.json
job_id="$(read_json_field /tmp/cron-cli-add.json id)"
cron_cli list --all --json > /tmp/cron-cli-list.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const jobId = process.argv[1];
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-list.json", "utf8"));
if (!Array.isArray(value.jobs) || !value.jobs.some((job) => job.id === jobId && job.name === "cli cron smoke")) {
throw new Error("created job missing from cron list");
}
' "$job_id"
cron_cli show "$job_id" --json > /tmp/cron-cli-show.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const jobId = process.argv[1];
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-show.json", "utf8"));
if (value.id !== jobId || value.name !== "cli cron smoke") {
throw new Error("cron show returned the wrong job");
}
' "$job_id"
cron_cli disable "$job_id" > /tmp/cron-cli-disable.json
cron_cli enable "$job_id" > /tmp/cron-cli-enable.json
cron_cli run "$job_id" --wait --wait-timeout 120s --poll-interval 500ms > /tmp/cron-cli-run.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-run.json", "utf8"));
if (value.completed !== true || value.status !== "ok") {
throw new Error(`cron run did not complete ok: ${JSON.stringify(value)}`);
}
'
cron_cli runs --id "$job_id" --limit 5 > /tmp/cron-cli-runs.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-runs.json", "utf8"));
const matching = Array.isArray(value.entries)
? value.entries.find((entry) => entry.status === "ok" && entry.summary === "openclaw-cli-cron-ok")
: undefined;
if (!matching) {
throw new Error("cron runs missing successful command summary");
}
'
cron_cli rm "$job_id" --json > /tmp/cron-cli-remove.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-remove.json", "utf8"));
if (value.ok !== true) {
throw new Error("cron remove failed");
}
'
node --input-type=module -e '
process.stdout.write(JSON.stringify({ ok: true, jobId: process.argv[1] }) + "\n");
' "$job_id"
INNER
status=${PIPESTATUS[0]}
set -e
if [ "$status" -ne 0 ]; then
echo "Docker cron CLI smoke failed"
docker_e2e_print_log "$CLIENT_LOG"
exit "$status"
fi
docker_e2e_print_log "$CLIENT_LOG"
echo "OK"

View File

@@ -7,7 +7,6 @@ import { readPluginInstallIndex } from "../plugin-index-sqlite.mjs";
const command = process.argv[2];
const SCENARIOS = new Set([
"base",
"acpx-openclaw-tools-bridge",
"feishu-channel",
"bootstrap-persona",
"channel-post-core-restore",
@@ -313,16 +312,6 @@ function assertConfigSurvived() {
}
}
if (hasCoverage(coverage) && acceptsIntent(coverage, "acpx-openclaw-tools-bridge")) {
const pluginAllow = config.plugins?.allow ?? [];
assert(pluginAllow.includes("acpx"), "ACPX plugin allow entry missing");
assert(config.plugins?.entries?.acpx?.enabled === true, "ACPX plugin entry changed");
assert(
config.plugins?.entries?.acpx?.config?.openClawToolsMcpBridge === true,
"ACPX OpenClaw tools bridge config changed",
);
}
if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) {
const pluginAllow = config.plugins?.allow ?? [];
assert(pluginAllow.includes("discord"), "configured install discord allow entry missing");

View File

@@ -108,17 +108,6 @@ const representativeConfigSteps = [
];
const scenarioConfigSteps = new Map([
[
"acpx-openclaw-tools-bridge",
[
configSetJsonFile(
"plugins-acpx-openclaw-tools-bridge",
"acpx-openclaw-tools-bridge",
"plugins",
"plugins-acpx-openclaw-tools-bridge.json",
),
],
],
[
"feishu-channel",
[
@@ -185,15 +174,6 @@ function selectedScenario() {
}
function adaptStepForBaseline(step, baselineVersion, summary) {
if (
step.intent === "acpx-openclaw-tools-bridge" &&
isReleaseBefore(baselineVersion, "2026.4.22")
) {
if (!summary.skippedIntents.includes("acpx-openclaw-tools-bridge")) {
summary.skippedIntents.push("acpx-openclaw-tools-bridge");
}
return null;
}
if (!isReleaseBefore(baselineVersion, "2026.4.0")) {
return step;
}

View File

@@ -1,21 +0,0 @@
{
"enabled": true,
"allow": ["acpx", "discord", "memory", "telegram", "whatsapp"],
"entries": {
"acpx": {
"enabled": true,
"config": {
"openClawToolsMcpBridge": true
}
},
"discord": {
"enabled": true
},
"telegram": {
"enabled": true
},
"whatsapp": {
"enabled": true
}
}
}

View File

@@ -1168,7 +1168,7 @@ refresh_gateway_service_if_loaded() {
if ! "$claw" gateway restart >/dev/null 2>&1; then
emit_json '{"event":"step","name":"gateway-service","status":"warn","reason":"restart-failed"}'
log "Warning: gateway service restart failed; continuing. Run: openclaw gateway restart"
log "Warning: gateway service restart failed; continuing."
return 0
fi

View File

@@ -1401,7 +1401,7 @@ function Refresh-GatewayServiceIfLoaded {
Invoke-OpenClawCommand gateway status --json | Out-Null
Write-Host "[OK] Gateway service refreshed" -ForegroundColor Green
} catch {
Write-Host "[!] Gateway service restart failed; continuing. Run: openclaw gateway restart" -ForegroundColor Yellow
Write-Host "[!] Gateway service restart failed; continuing." -ForegroundColor Yellow
}
}

View File

@@ -3000,7 +3000,7 @@ refresh_gateway_service_if_loaded() {
if run_quiet_step "Restarting gateway service" "$claw" gateway restart; then
ui_success "Gateway service restarted"
else
ui_warn "Gateway service restart failed; continuing. Run: openclaw gateway restart"
ui_warn "Gateway service restart failed; continuing"
return 0
fi

View File

@@ -75,7 +75,6 @@ function sanitizeLaneNameSuffix(value) {
const UPGRADE_SURVIVOR_SCENARIOS = [
"base",
"acpx-openclaw-tools-bridge",
"feishu-channel",
"bootstrap-persona",
"channel-post-core-restore",
@@ -184,17 +183,6 @@ function supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec) {
return comparePublishedReleaseVersion(version, { year: 2026, month: 4, patch: 23 }) >= 0;
}
function supportsUpgradeSurvivorAcpToolsBridge(baselineSpec) {
if (!baselineSpec) {
return true;
}
const version = parsePublishedReleaseVersion(baselineSpec);
if (!version) {
return true;
}
return comparePublishedReleaseVersion(version, { year: 2026, month: 4, patch: 22 }) >= 0;
}
function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScenarios = "") {
const baselineSpecs = parseUpgradeSurvivorBaselineSpecs(rawBaselineSpecs);
const scenarios = parseUpgradeSurvivorScenarios(rawScenarios);
@@ -211,10 +199,8 @@ function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScen
matrixScenarios
.filter(
(scenario) =>
(scenario !== "plugin-deps-cleanup" ||
supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec)) &&
(scenario !== "acpx-openclaw-tools-bridge" ||
supportsUpgradeSurvivorAcpToolsBridge(baselineSpec)),
scenario !== "plugin-deps-cleanup" ||
supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec),
)
.map((scenario) => {
const suffixParts = [

View File

@@ -114,10 +114,10 @@
}
},
"install": {
"npmSpec": "@tencent-weixin/openclaw-weixin@2.4.6",
"npmSpec": "@tencent-weixin/openclaw-weixin@2.4.3",
"defaultChoice": "npm",
"expectedIntegrity": "sha512-qw9k3PLTiMWGNjjsknHgcTManH1w4j+Ji1ArWIaYLKCq3aFRsVwcqnPi127bvOoVMJGW4dbyJ8NECEMgoO+iRw==",
"minHostVersion": ">=2026.5.12"
"expectedIntegrity": "sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==",
"minHostVersion": ">=2026.3.22"
}
}
},

View File

@@ -1,761 +0,0 @@
{
// Internal maintainer inventory for `pnpm policy:config-coverage`.
// Keep this report-only by default: it helps policy maintainers notice config
// drift without making every config PR author update Policy.
"monitored": [
"auth.profiles.*.mode",
"auth.profiles.*.provider",
"browser.ssrfPolicy.allowPrivateNetwork",
"browser.ssrfPolicy.dangerouslyAllowPrivateNetwork",
"channels.*.accounts.*.dmPolicy",
"channels.*.accounts.*.groupPolicy",
"channels.*.accounts.*.groups.*.requireMention",
"channels.*.dmPolicy",
"channels.*.enabled",
"channels.*.groupPolicy",
"channels.*.groups.*.requireMention",
"diagnostics.otel.captureContent",
"gateway.auth.mode",
"gateway.auth.rateLimit.*",
"gateway.bind",
"gateway.controlUi.allowInsecureAuth",
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
"gateway.controlUi.dangerouslyDisableDeviceAuth",
"gateway.customBindHost",
"gateway.http.endpoints.*.*.allowUrl",
"gateway.http.endpoints.*.*.urlAllowlist.*",
"gateway.http.endpoints.*.enabled",
"gateway.mode",
"gateway.remote.enabled",
"gateway.tailscale.mode",
"gateway.tailscale.preserveFunnel",
"logging.redactSensitive",
"memory.qmd.sessions.enabled",
"mcp.servers.*.command",
"mcp.servers.*.transport",
"mcp.servers.*.url",
"models.providers.*.type",
"models.selected",
"models.selectedByAgent.*",
"models.selectedByChannel.*",
"session.dmScope",
"session.maintenance.mode",
"secrets.defaults.provider",
"secrets.providers.*.allowInsecureTransport",
"secrets.providers.*.source",
"tools.allow.*",
"tools.alsoAllow.*",
"tools.deny.*",
"tools.elevated.allowFrom.*.*",
"tools.elevated.enabled",
"tools.exec.ask",
"tools.exec.host",
"tools.exec.security",
"tools.fs.workspaceOnly",
"tools.profile",
"tools.sandbox.tools.allow.*",
"tools.sandbox.tools.alsoAllow.*",
"tools.sandbox.tools.deny.*",
"tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange",
"tools.web.fetch.ssrfPolicy.allowPrivateNetwork",
"tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange",
"tools.web.fetch.ssrfPolicy.dangerouslyAllowPrivateNetwork",
"agents.defaults.memorySearch.enabled",
"agents.defaults.memorySearch.experimental.sessionMemory",
"agents.defaults.memorySearch.sources.*",
"agents.defaults.model.fallbacks.*",
"agents.defaults.model.primary",
"agents.defaults.models.*.alias",
"agents.defaults.sandbox.backend",
"agents.defaults.sandbox.browser.binds.*",
"agents.defaults.sandbox.browser.cdpSourceRange",
"agents.defaults.sandbox.docker.apparmorProfile",
"agents.defaults.sandbox.docker.binds.*",
"agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
"agents.defaults.sandbox.docker.network",
"agents.defaults.sandbox.docker.readOnlyRoot",
"agents.defaults.sandbox.docker.seccompProfile",
"agents.defaults.sandbox.mode",
"agents.defaults.sandbox.workspaceAccess",
"agents.defaults.tools.allow.*",
"agents.defaults.tools.alsoAllow.*",
"agents.defaults.tools.deny.*",
"agents.defaults.tools.elevated.allowFrom.*.*",
"agents.defaults.tools.elevated.enabled",
"agents.defaults.tools.exec.ask",
"agents.defaults.tools.exec.host",
"agents.defaults.tools.exec.security",
"agents.defaults.tools.fs.workspaceOnly",
"agents.defaults.tools.profile",
"agents.defaults.tools.sandbox.tools.allow.*",
"agents.defaults.tools.sandbox.tools.alsoAllow.*",
"agents.defaults.tools.sandbox.tools.deny.*",
"agents.list.*.memorySearch.enabled",
"agents.list.*.memorySearch.experimental.sessionMemory",
"agents.list.*.memorySearch.sources.*",
"agents.list.*.model.fallbacks.*",
"agents.list.*.model.primary",
"agents.list.*.models.*.alias",
"agents.list.*.sandbox.backend",
"agents.list.*.sandbox.browser.binds.*",
"agents.list.*.sandbox.browser.cdpSourceRange",
"agents.list.*.sandbox.docker.apparmorProfile",
"agents.list.*.sandbox.docker.binds.*",
"agents.list.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
"agents.list.*.sandbox.docker.network",
"agents.list.*.sandbox.docker.readOnlyRoot",
"agents.list.*.sandbox.docker.seccompProfile",
"agents.list.*.sandbox.mode",
"agents.list.*.sandbox.workspaceAccess",
"agents.list.*.tools.allow.*",
"agents.list.*.tools.alsoAllow.*",
"agents.list.*.tools.deny.*",
"agents.list.*.tools.elevated.allowFrom.*.*",
"agents.list.*.tools.elevated.enabled",
"agents.list.*.tools.exec.ask",
"agents.list.*.tools.exec.host",
"agents.list.*.tools.exec.security",
"agents.list.*.tools.fs.workspaceOnly",
"agents.list.*.tools.profile",
"agents.list.*.tools.sandbox.tools.allow.*",
"agents.list.*.tools.sandbox.tools.alsoAllow.*",
"agents.list.*.tools.sandbox.tools.deny.*",
],
"classifications": [
{
"pattern": "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy observes private-network browser SSRF posture.",
},
{
"pattern": "browser.ssrfPolicy.allowPrivateNetwork",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy observes the legacy browser private-network toggle.",
"allowNoSchemaPath": true,
},
{
"pattern": "tools.web.fetch.ssrfPolicy.dangerouslyAllowPrivateNetwork",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy observes private-network web-fetch SSRF posture.",
"allowNoSchemaPath": true,
},
{
"pattern": "tools.web.fetch.ssrfPolicy.allowPrivateNetwork",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy observes the legacy web-fetch private-network toggle.",
"allowNoSchemaPath": true,
},
{
"pattern": "tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy treats RFC 2544 benchmark ranges as private-network posture.",
},
{
"pattern": "tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy treats IPv6 unique-local ranges as private-network posture.",
},
{
"pattern": "session.dmScope",
"status": "observed",
"area": "ingress",
"policy": "ingress.session.requireDmScope",
"reason": "Policy observes direct-message session isolation scope.",
},
{
"pattern": "logging.redactSensitive",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.sensitiveLogging.requireRedaction",
"reason": "Policy observes sensitive log redaction posture.",
"allowNoSchemaPath": true,
},
{
"pattern": "diagnostics.otel.captureContent",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.telemetry.denyContentCapture",
"reason": "Policy observes telemetry content-capture posture.",
"allowNoSchemaPath": true,
},
{
"pattern": "session.maintenance.mode",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.retention.requireSessionMaintenance",
"reason": "Policy observes session maintenance enforcement posture.",
},
{
"pattern": "memory.qmd.sessions.enabled",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes QMD session-transcript indexing.",
},
{
"pattern": "agents.defaults.memorySearch.enabled",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes default memory-search session indexing enablement.",
},
{
"pattern": "agents.defaults.memorySearch.experimental.sessionMemory",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes default memory-search session-memory toggle.",
},
{
"pattern": "agents.defaults.memorySearch.sources.*",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes whether default memory-search sources include sessions.",
},
{
"pattern": "agents.list.*.memorySearch.enabled",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes per-agent memory-search session indexing enablement.",
},
{
"pattern": "agents.list.*.memorySearch.experimental.sessionMemory",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes per-agent memory-search session-memory toggle.",
},
{
"pattern": "agents.list.*.memorySearch.sources.*",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes whether per-agent memory-search sources include sessions.",
},
{
"pattern": "auth.profiles.*.mode",
"status": "observed",
"area": "auth",
"policy": "auth.profiles.allowModes",
"reason": "Policy observes configured auth profile mode metadata.",
},
{
"pattern": "auth.profiles.*.provider",
"status": "observed",
"area": "auth",
"policy": "auth.profiles.requireMetadata",
"reason": "Policy observes configured auth profile provider metadata.",
},
{
"pattern": "channels.*.enabled",
"status": "observed",
"area": "channels",
"policy": "channels.denyRules",
"reason": "Provider deny rules only apply to enabled configured channels.",
},
{
"pattern": "channels.*.accounts.*.dmPolicy",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.allowDmPolicies",
"reason": "Policy observes account-level direct-message access posture.",
},
{
"pattern": "channels.*.dmPolicy",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.allowDmPolicies",
"reason": "Policy observes channel-level direct-message access posture.",
},
{
"pattern": "channels.*.accounts.*.groupPolicy",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.denyOpenGroups",
"reason": "Policy observes account-level group access posture.",
},
{
"pattern": "channels.*.groupPolicy",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.denyOpenGroups",
"reason": "Policy observes channel-level group access posture.",
},
{
"pattern": "channels.*.accounts.*.groups.*.requireMention",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.requireMentionInGroups",
"reason": "Policy observes account group mention gates.",
},
{
"pattern": "channels.*.groups.*.requireMention",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.requireMentionInGroups",
"reason": "Policy observes channel group mention gates.",
},
{
"pattern": "gateway.bind",
"status": "observed",
"area": "gateway",
"policy": "gateway.exposure.allowNonLoopbackBind",
"reason": "Policy observes Gateway bind exposure posture.",
},
{
"pattern": "gateway.customBindHost",
"status": "observed",
"area": "gateway",
"policy": "gateway.exposure.allowNonLoopbackBind",
"reason": "Policy observes custom bind host exposure posture.",
},
{
"pattern": "gateway.tailscale.mode",
"status": "observed",
"area": "gateway",
"policy": "gateway.exposure.allowTailscaleFunnel",
"reason": "Policy observes Tailscale serve/funnel mode when deriving Gateway exposure posture.",
},
{
"pattern": "gateway.tailscale.preserveFunnel",
"status": "observed",
"area": "gateway",
"policy": "gateway.exposure.allowTailscaleFunnel",
"reason": "Policy observes preserveFunnel because serve mode can preserve Funnel exposure.",
},
{
"pattern": "gateway.auth.mode",
"status": "observed",
"area": "gateway",
"policy": "gateway.auth.requireAuth",
"reason": "Policy observes Gateway auth mode posture.",
},
{
"pattern": "gateway.auth.rateLimit.*",
"status": "observed",
"area": "gateway",
"policy": "gateway.auth.requireExplicitRateLimit",
"reason": "Policy observes whether Gateway auth rate limiting is explicitly configured.",
},
{
"pattern": "gateway.controlUi.allowInsecureAuth",
"status": "observed",
"area": "gateway",
"policy": "gateway.controlUi.allowInsecure",
"reason": "Policy observes the Control UI insecure auth toggle.",
},
{
"pattern": "gateway.controlUi.dangerouslyDisableDeviceAuth",
"status": "observed",
"area": "gateway",
"policy": "gateway.controlUi.allowInsecure",
"reason": "Policy observes the Control UI device-auth disable toggle.",
},
{
"pattern": "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
"status": "observed",
"area": "gateway",
"policy": "gateway.controlUi.allowInsecure",
"reason": "Policy observes the Control UI Host-header origin fallback toggle.",
},
{
"pattern": "gateway.mode",
"status": "observed",
"area": "gateway",
"policy": "gateway.remote.allow",
"reason": "Policy observes whether Gateway remote mode is enabled.",
},
{
"pattern": "gateway.remote.enabled",
"status": "observed",
"area": "gateway",
"policy": "gateway.remote.allow",
"reason": "Policy observes explicit remote Gateway enablement.",
},
{
"pattern": "gateway.http.endpoints.*.enabled",
"status": "observed",
"area": "gateway",
"policy": "gateway.http.denyEndpoints",
"reason": "Policy observes Gateway HTTP endpoint enablement.",
},
{
"pattern": "gateway.http.endpoints.*.*.allowUrl",
"status": "observed",
"area": "gateway",
"policy": "gateway.http.requireUrlAllowlists",
"reason": "Policy observes URL-fetch enablement on Gateway HTTP inputs.",
},
{
"pattern": "gateway.http.endpoints.*.*.urlAllowlist.*",
"status": "observed",
"area": "gateway",
"policy": "gateway.http.requireUrlAllowlists",
"reason": "Policy observes URL-fetch allowlists on Gateway HTTP inputs.",
},
{
"pattern": "mcp.servers.*.command",
"status": "observed",
"area": "mcp",
"policy": "mcp.servers.allow / mcp.servers.deny",
"reason": "Policy observes configured MCP server ids and command posture context.",
},
{
"pattern": "mcp.servers.*.transport",
"status": "observed",
"area": "mcp",
"policy": "mcp.servers.allow / mcp.servers.deny",
"reason": "Policy observes configured MCP server transport posture context.",
},
{
"pattern": "mcp.servers.*.url",
"status": "observed",
"area": "mcp",
"policy": "mcp.servers.allow / mcp.servers.deny",
"reason": "Policy observes configured MCP server URL posture context.",
},
{
"pattern": "models.providers.*.type",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes configured provider ids.",
"allowNoSchemaPath": true,
},
{
"pattern": "models.selected",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes selected model refs.",
"allowNoSchemaPath": true,
},
{
"pattern": "models.selectedByAgent.*",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes agent-specific selected model refs.",
"allowNoSchemaPath": true,
},
{
"pattern": "models.selectedByChannel.*",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes channel-specific selected model refs.",
"allowNoSchemaPath": true,
},
{
"pattern": "agents.defaults.model.**",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes default agent model refs.",
},
{
"pattern": "agents.defaults.models.*.alias",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes default agent model aliases.",
},
{
"pattern": "agents.list.*.model.**",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes per-agent model refs.",
},
{
"pattern": "agents.list.*.models.*.alias",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes per-agent model aliases.",
},
{
"pattern": "secrets.defaults.provider",
"status": "observed",
"area": "secrets",
"policy": "secrets.requireManagedProviders",
"reason": "Policy observes default SecretRef provider provenance.",
"allowNoSchemaPath": true,
},
{
"pattern": "secrets.providers.*.source",
"status": "observed",
"area": "secrets",
"policy": "secrets.denySources",
"reason": "Policy observes configured secret provider source type.",
},
{
"pattern": "secrets.providers.*.allowInsecureTransport",
"status": "observed",
"area": "secrets",
"policy": "secrets.allowInsecureProviders",
"reason": "Policy observes insecure secret-provider transport posture.",
"allowNoSchemaPath": true,
},
{
"pattern": "tools.profile",
"status": "observed",
"area": "tools",
"policy": "tools.profiles.allow",
"reason": "Policy observes global tool profile posture.",
},
{
"pattern": "tools.fs.workspaceOnly",
"status": "observed",
"area": "tools",
"policy": "tools.fs.requireWorkspaceOnly",
"reason": "Policy observes global filesystem workspace-only posture.",
},
{
"pattern": "tools.exec.security",
"status": "observed",
"area": "tools",
"policy": "tools.exec.allowSecurity",
"reason": "Policy observes global exec security posture.",
},
{
"pattern": "tools.exec.ask",
"status": "observed",
"area": "tools",
"policy": "tools.exec.requireAsk",
"reason": "Policy observes global exec approval posture.",
},
{
"pattern": "tools.exec.host",
"status": "observed",
"area": "tools",
"policy": "tools.exec.allowHosts",
"reason": "Policy observes global exec host routing posture.",
},
{
"pattern": "tools.elevated.enabled",
"status": "observed",
"area": "tools",
"policy": "tools.elevated.allow",
"reason": "Policy observes global elevated tool posture.",
},
{
"pattern": "tools.elevated.allowFrom.*.*",
"status": "observed",
"area": "tools",
"policy": "tools.elevated.allow",
"reason": "Policy observes global elevated provider allowlists.",
},
{
"pattern": "tools.allow.*",
"status": "observed",
"area": "tools",
"policy": "tool posture evidence",
"reason": "Policy includes global tool allow posture in evidence for attestation drift.",
},
{
"pattern": "tools.alsoAllow.*",
"status": "observed",
"area": "tools",
"policy": "tools.alsoAllow.expected",
"reason": "Policy observes global tools.alsoAllow posture.",
},
{
"pattern": "tools.deny.*",
"status": "observed",
"area": "tools",
"policy": "tools.denyTools",
"reason": "Policy observes global tool deny posture.",
},
{
"pattern": "tools.sandbox.tools.*.*",
"status": "observed",
"area": "tools",
"policy": "tools.denyTools",
"reason": "Policy observes global sandbox tool posture.",
},
{
"pattern": "agents.*.tools.**",
"status": "observed",
"area": "tools",
"policy": "tools.* scoped by agentIds",
"reason": "Policy observes default and per-agent tool posture overrides.",
"allowNoSchemaPath": true,
},
{
"pattern": "agents.list.*.tools.**",
"status": "observed",
"area": "tools",
"policy": "tools.* scoped by agentIds",
"reason": "Policy observes per-agent tool posture overrides.",
},
{
"pattern": "agents.*.sandbox.mode",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.requireMode",
"reason": "Policy observes sandbox mode posture.",
},
{
"pattern": "agents.list.*.sandbox.mode",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.requireMode",
"reason": "Policy observes per-agent sandbox mode posture.",
},
{
"pattern": "agents.*.sandbox.backend",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.allowBackends",
"reason": "Policy observes sandbox backend posture.",
},
{
"pattern": "agents.list.*.sandbox.backend",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.allowBackends",
"reason": "Policy observes per-agent sandbox backend posture.",
},
{
"pattern": "agents.*.sandbox.workspaceAccess",
"status": "observed",
"area": "agents",
"policy": "agents.workspace.allowedAccess",
"reason": "Policy observes sandbox workspace access posture.",
},
{
"pattern": "agents.list.*.sandbox.workspaceAccess",
"status": "observed",
"area": "agents",
"policy": "agents.workspace.allowedAccess",
"reason": "Policy observes per-agent sandbox workspace access posture.",
},
{
"pattern": "agents.*.sandbox.docker.network",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyHostNetwork and sandbox.containers.denyContainerNamespaceJoin",
"reason": "Policy observes Docker container network posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.network",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyHostNetwork and sandbox.containers.denyContainerNamespaceJoin",
"reason": "Policy observes per-agent Docker container network posture.",
},
{
"pattern": "agents.*.sandbox.docker.binds.*",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts and sandbox.containers.denyContainerRuntimeSocketMounts",
"reason": "Policy observes Docker bind mount posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.binds.*",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts and sandbox.containers.denyContainerRuntimeSocketMounts",
"reason": "Policy observes per-agent Docker bind mount posture.",
},
{
"pattern": "agents.*.sandbox.browser.binds.*",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts",
"reason": "Policy observes sandbox browser bind mount posture.",
},
{
"pattern": "agents.list.*.sandbox.browser.binds.*",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts",
"reason": "Policy observes per-agent sandbox browser bind mount posture.",
},
{
"pattern": "agents.*.sandbox.docker.apparmorProfile",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyUnconfinedProfiles",
"reason": "Policy observes Docker AppArmor profile posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.apparmorProfile",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyUnconfinedProfiles",
"reason": "Policy observes per-agent Docker AppArmor profile posture.",
},
{
"pattern": "agents.*.sandbox.docker.seccompProfile",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyUnconfinedProfiles",
"reason": "Policy observes Docker seccomp profile posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.seccompProfile",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyUnconfinedProfiles",
"reason": "Policy observes per-agent Docker seccomp profile posture.",
},
{
"pattern": "agents.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyContainerNamespaceJoin",
"reason": "Policy observes explicit Docker namespace-join escape posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyContainerNamespaceJoin",
"reason": "Policy observes explicit per-agent Docker namespace-join escape posture.",
},
{
"pattern": "agents.*.sandbox.docker.readOnlyRoot",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts",
"reason": "Policy observes Docker read-only root posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.readOnlyRoot",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts",
"reason": "Policy observes per-agent Docker read-only root posture.",
},
{
"pattern": "agents.*.sandbox.browser.cdpSourceRange",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.browser.requireCdpSourceRange",
"reason": "Policy observes sandbox browser CDP source range posture.",
},
{
"pattern": "agents.list.*.sandbox.browser.cdpSourceRange",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.browser.requireCdpSourceRange",
"reason": "Policy observes per-agent sandbox browser CDP source range posture.",
},
],
}

454
scripts/native-app-i18n.ts Normal file
View File

@@ -0,0 +1,454 @@
import { createHash } from "node:crypto";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { translateNativeEntries } from "./control-ui-i18n.ts";
export type NativeI18nSurface = "android" | "apple";
export const NATIVE_I18N_LOCALES = [
"zh-CN",
"zh-TW",
"pt-BR",
"de",
"es",
"ja-JP",
"ko",
"fr",
"hi",
"ar",
"it",
"tr",
"uk",
"id",
"pl",
"th",
"vi",
"nl",
"fa",
"ru",
] as const;
export type NativeI18nEntry = {
id: string;
kind: string;
line: number;
path: string;
source: string;
surface: NativeI18nSurface;
};
type Candidate = Omit<NativeI18nEntry, "id">;
type NativeTranslationArtifact = {
entries: Array<{ id: string; source: string; translated: string }>;
locale: string;
version: 1;
};
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
const OUTPUT_PATH = path.join(ROOT, "apps", ".i18n", "native-source.json");
const TRANSLATIONS_DIR = path.join(ROOT, "apps", ".i18n", "native");
const SOURCE_ROOTS: Record<NativeI18nSurface, string[]> = {
android: [path.join(ROOT, "apps", "android", "app", "src", "main")],
apple: [
path.join(ROOT, "apps", "ios"),
path.join(ROOT, "apps", "macos", "Sources"),
path.join(ROOT, "apps", "shared", "OpenClawKit", "Sources"),
],
};
const ANDROID_EXTENSIONS = new Set([".kt", ".kts"]);
const APPLE_EXTENSIONS = new Set([".swift", ".plist"]);
const APPLE_UI_CALLS =
/(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
const APPLE_MODIFIER_CALLS =
/\.(?:navigationTitle|accessibilityLabel|accessibilityHint|help|alert|confirmationDialog)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
const ANDROID_CALLS =
/\b(?:Text|OutlinedTextField|BasicTextField|Button|IconButton|TopAppBar|Snackbar|AlertDialog)\s*\(\s*(?:text\s*=\s*)?"((?:\\.|[^"\\])*)"/gu;
const ANDROID_PROPERTIES =
/\b(?:contentDescription|label|placeholder|title|message|supportingText)\s*=\s*"((?:\\.|[^"\\])*)"/gu;
const ANDROID_WRAPPER_ARGS =
/\b[A-Z][A-Za-z0-9_]*\s*\([^)\n]{0,160}?\b(?:text|title|label|message|contentDescription|placeholder)\s*=\s*"((?:\\.|[^"\\])*)"/gu;
const ANDROID_TOAST_ARGS =
/\b(?:Toast\.makeText|Snackbar\.make)\s*\([^,\n]*,\s*"((?:\\.|[^"\\])*)"/gu;
const ANDROID_DIALOG_CALLS =
/\.(?:setTitle|setMessage|setPositiveButton|setNegativeButton|setNeutralButton)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
const ANDROID_STATE_CALLS = /\b(?:MutableStateFlow|StateFlow|flowOf)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
const CONDITIONAL_BRANCHES = [
/\bif\s*\([^)]*\)\s*"((?:\\.|[^"\\])*)"\s*else\s*"((?:\\.|[^"\\])*)"/gu,
/\?\s*"((?:\\.|[^"\\])*)"\s*:\s*"((?:\\.|[^"\\])*)"/gu,
];
const ANDROID_RESOURCE_STRINGS = /<string\b[^>]*>([\s\S]*?)<\/string>/gu;
const APPLE_NAMED_ARGUMENTS =
/\b(?:title|subtitle|label|message|text|prompt|description|help)\s*:\s*"((?:\\.|[^"\\])*)"/gu;
const APPLE_PLIST_STRINGS = /<string>([\s\S]*?)<\/string>/gu;
const GENERATED_PATH_RE = /(?:^|[\\/])(?:build|\.gradle|\.build|DerivedData)(?:$|[\\/])/u;
const EXCLUDED_PATH_RE = /(?:^|[\\/])(?:Tests?|UITests?|test|Preview(?:s)?)(?:$|[\\/])/u;
const EXCLUDED_FILE_RE = /(?:Tests?|UITests?|Previews?|Testing)\.(?:swift|kt|kts)$/u;
const BUILD_SETTING_RE = /\$\([A-Za-z0-9_.-]+\)/gu;
const NATIVE_I18N_LOCALE_SET = new Set<string>(NATIVE_I18N_LOCALES);
function extractSwiftInterpolations(source: string): string[] | null {
const values: string[] = [];
for (let index = 0; index < source.length; index += 1) {
if (source[index] !== "\\" || source[index + 1] !== "(") continue;
const start = index;
let depth = 1;
let quoted = false;
let escaped = false;
for (index += 2; index < source.length; index += 1) {
const character = source[index];
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') quoted = !quoted;
else if (!quoted && character === "(") depth += 1;
else if (!quoted && character === ")") {
depth -= 1;
if (depth === 0) {
values.push(source.slice(start, index + 1));
break;
}
}
}
if (depth !== 0) return null;
}
return values;
}
function extractKotlinInterpolations(source: string): string[] | null {
const values = [...source.matchAll(/\$[A-Za-z_][A-Za-z0-9_]*/gu)].map((match) => match[0]);
for (let index = 0; index < source.length; index += 1) {
if (source[index] !== "$" || source[index + 1] !== "{") continue;
const start = index;
let depth = 1;
for (index += 2; index < source.length; index += 1) {
if (source[index] === "{") depth += 1;
else if (source[index] === "}") {
depth -= 1;
if (depth === 0) {
values.push(source.slice(start, index + 1));
break;
}
}
}
if (depth !== 0) return null;
}
return values;
}
function lineNumber(source: string, offset: number): number {
return source.slice(0, offset).split("\n").length;
}
function decodeLiteral(raw: string): string {
try {
return JSON.parse(`"${raw}"`) as string;
} catch {
return raw;
}
}
function normalizeSource(source: string): string {
return source;
}
function structuralTokenSignature(source: string): string {
const swift = extractSwiftInterpolations(source);
const kotlin = extractKotlinInterpolations(source);
const buildSettings = source.match(BUILD_SETTING_RE) ?? [];
const lineBreaks = (source.match(/\n/gu) ?? []).length;
return JSON.stringify({ swift, kotlin, buildSettings, lineBreaks });
}
function isTranslatableCandidate(source: string, kind: string): boolean {
if (BUILD_SETTING_RE.test(source)) {
BUILD_SETTING_RE.lastIndex = 0;
return false;
}
BUILD_SETTING_RE.lastIndex = 0;
if (/^[a-z0-9_.:/$-]+$/u.test(source) || /^[A-Z0-9_.:/$-]+$/u.test(source)) {
return false;
}
if (/[{}[\]]/u.test(source) && !/(?:\\\(|\$\{)/u.test(source)) {
return false;
}
return kind !== "plist-string" || /\s/u.test(source);
}
function addCandidate(
entries: Candidate[],
surface: NativeI18nSurface,
repoPath: string,
source: string,
kind: string,
line: number,
) {
const normalized = normalizeSource(decodeLiteral(source));
if (!normalized.trim() || !/\p{L}/u.test(normalized)) {
return;
}
if (!isTranslatableCandidate(normalized, kind)) {
return;
}
if (
normalized.length > 500 ||
extractSwiftInterpolations(normalized) === null ||
extractKotlinInterpolations(normalized) === null
) {
return;
}
entries.push({ kind, line, path: repoPath, source: normalized, surface });
}
function extractCandidates(
surface: NativeI18nSurface,
repoPath: string,
source: string,
): Candidate[] {
const entries: Candidate[] = [];
const patterns =
surface === "apple"
? [
[APPLE_UI_CALLS, "ui-call"],
[APPLE_MODIFIER_CALLS, "ui-modifier"],
[APPLE_NAMED_ARGUMENTS, "ui-named-argument"],
...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const),
]
: [
[ANDROID_CALLS, "ui-call"],
[ANDROID_PROPERTIES, "ui-property"],
[ANDROID_WRAPPER_ARGS, "ui-wrapper-argument"],
[ANDROID_TOAST_ARGS, "ui-toast"],
[ANDROID_DIALOG_CALLS, "ui-dialog"],
[ANDROID_STATE_CALLS, "ui-state"],
...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const),
];
for (const [pattern, kind] of patterns) {
for (const match of source.matchAll(pattern)) {
const offset = match.index ?? 0;
for (const value of match.slice(1)) {
if (value) {
addCandidate(entries, surface, repoPath, value, kind, lineNumber(source, offset));
}
}
}
}
if (surface === "android" && repoPath.endsWith("/res/values/strings.xml")) {
for (const match of source.matchAll(ANDROID_RESOURCE_STRINGS)) {
if (match[1])
addCandidate(
entries,
surface,
repoPath,
match[1],
"resource-string",
lineNumber(source, match.index ?? 0),
);
}
}
if (surface === "apple" && repoPath.endsWith(".plist")) {
for (const match of source.matchAll(APPLE_PLIST_STRINGS)) {
if (match[1])
addCandidate(
entries,
surface,
repoPath,
match[1],
"plist-string",
lineNumber(source, match.index ?? 0),
);
}
}
return entries;
}
async function walkFiles(
root: string,
surface: NativeI18nSurface,
out: string[] = [],
): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
if (entry.isDirectory()) {
if (GENERATED_PATH_RE.test(fullPath) || EXCLUDED_PATH_RE.test(fullPath)) {
continue;
}
await walkFiles(fullPath, surface, out);
continue;
}
const extension = path.extname(entry.name);
const allowed =
surface === "apple"
? APPLE_EXTENSIONS
: fullPath.endsWith(`${path.sep}res${path.sep}values${path.sep}strings.xml`)
? new Set([...ANDROID_EXTENSIONS, ".xml"])
: ANDROID_EXTENSIONS;
if (entry.isFile() && allowed.has(extension) && !EXCLUDED_FILE_RE.test(entry.name)) {
out.push(fullPath);
}
}
return out;
}
function withIds(entries: Candidate[]): NativeI18nEntry[] {
const seen = new Set<string>();
const unique = [
...new Map(
entries.map((entry) => [`${entry.surface}\u0000${entry.path}\u0000${entry.source}`, entry]),
).values(),
];
return unique
.toSorted(
(left, right) =>
left.surface.localeCompare(right.surface) ||
left.path.localeCompare(right.path) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.source.localeCompare(right.source),
)
.map((entry) => {
const digest = createHash("sha256")
.update([entry.surface, entry.path, entry.kind, entry.source].join("\u0000"))
.digest("hex")
.slice(0, 16);
let id = `native.${entry.surface}.${digest}`;
if (seen.has(id)) {
id = `${id}.${entry.line}`;
}
seen.add(id);
return { ...entry, id };
});
}
export async function collectNativeI18nEntries(): Promise<NativeI18nEntry[]> {
const entries: Candidate[] = [];
for (const surface of ["android", "apple"] as const) {
for (const sourceRoot of SOURCE_ROOTS[surface]) {
const files = await walkFiles(sourceRoot, surface);
for (const filePath of files.toSorted()) {
const source = await readFile(filePath, "utf8");
const repoPath = path.relative(ROOT, filePath).split(path.sep).join("/");
entries.push(...extractCandidates(surface, repoPath, source));
}
}
}
return withIds(entries);
}
function render(entries: NativeI18nEntry[]): string {
return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
}
export async function syncNativeI18n(options: { checkOnly: boolean; write: boolean }) {
const expected = render(await collectNativeI18nEntries());
let current = "";
try {
current = await readFile(OUTPUT_PATH, "utf8");
} catch {
// The first sync creates the inventory.
}
if (current !== expected && options.checkOnly) {
throw new Error(
"native app i18n inventory drift detected. Run `pnpm native:i18n:sync` and commit apps/.i18n/native-source.json.",
);
}
if (current !== expected && options.write) {
await mkdir(path.dirname(OUTPUT_PATH), { recursive: true });
await writeFile(OUTPUT_PATH, expected, "utf8");
}
const count = JSON.parse(expected).entries.length as number;
process.stdout.write(`native-app-i18n: entries=${count} changed=${current !== expected}\n`);
}
async function loadGlossary(locale: string): Promise<Array<{ source: string; target: string }>> {
try {
return JSON.parse(
await readFile(
path.join(ROOT, "ui", "src", "i18n", ".i18n", `glossary.${locale}.json`),
"utf8",
),
) as Array<{ source: string; target: string }>;
} catch {
return [];
}
}
async function syncNativeLocale(locale: string, entries: NativeI18nEntry[]) {
// Native runtime resources are owned by the Android and Apple slices; these
// artifacts keep the shared translation-memory handoff current between them.
const artifactPath = path.join(TRANSLATIONS_DIR, `${locale}.json`);
let previous: NativeTranslationArtifact = { entries: [], locale, version: 1 };
try {
previous = JSON.parse(await readFile(artifactPath, "utf8")) as NativeTranslationArtifact;
} catch {
// The first refresh creates the locale artifact.
}
const previousById = new Map(previous.entries.map((entry) => [entry.id, entry]));
const pending = entries
.filter((entry) => {
const current = previousById.get(entry.id);
return !current || current.source !== entry.source || !current.translated.trim();
})
.map((entry) => ({
id: entry.id,
source: entry.source,
sourcePath: entry.path,
}));
const translated = pending.length
? await translateNativeEntries(pending, locale, await loadGlossary(locale))
: new Map<string, string>();
const artifact: NativeTranslationArtifact = {
version: 1,
locale,
entries: entries.map((entry) => ({
id: entry.id,
source: entry.source,
translated:
translated.get(entry.id) ?? previousById.get(entry.id)?.translated ?? entry.source,
})),
};
for (const entry of artifact.entries) {
if (structuralTokenSignature(entry.source) !== structuralTokenSignature(entry.translated)) {
throw new Error(
`native translation changed placeholders or line breaks for ${locale}:${entry.id}`,
);
}
}
await mkdir(TRANSLATIONS_DIR, { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
process.stdout.write(
`native-app-i18n: locale=${locale} entries=${entries.length} translated=${translated.size}\n`,
);
}
async function main() {
const [command, ...args] = process.argv.slice(2);
if (command !== "check" && command !== "sync") {
throw new Error(
"usage: node --import tsx scripts/native-app-i18n.ts check|sync [--write] [--locale <code>]",
);
}
await syncNativeI18n({
checkOnly: command === "check",
write: command === "sync" && process.argv.includes("--write"),
});
const localeFlag = args.indexOf("--locale");
const locale = localeFlag >= 0 ? args[localeFlag + 1] : undefined;
if (locale) {
if (command !== "sync" || !process.argv.includes("--write")) {
throw new Error("native locale refresh requires `sync --write --locale <code>`");
}
if (!NATIVE_I18N_LOCALE_SET.has(locale)) {
throw new Error(
`unsupported native locale "${locale}". Expected one of: ${NATIVE_I18N_LOCALES.join(", ")}`,
);
}
await syncNativeLocale(locale, await collectNativeI18nEntries());
}
}
if (process.argv[1] && import.meta.url === `file://${path.resolve(process.argv[1])}`) {
await main();
}

View File

@@ -422,7 +422,6 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do
-e OPENCLAW_LIVE_TEST=1 \
-e OPENCLAW_LIVE_ACP_BIND=1 \
-e OPENCLAW_LIVE_ACP_BIND_AGENT="$ACP_AGENT" \
-e OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON="${OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON:-}" \
-e OPENCLAW_LIVE_ACP_BIND_TEST_FILES="${OPENCLAW_LIVE_ACP_BIND_TEST_FILES:-}" \
-e OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL="${OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL:-}" \
-e OPENCLAW_LIVE_ACP_BIND_SETUP_TIMEOUT_SECONDS="$ACP_SETUP_TIMEOUT_SECONDS" \

View File

@@ -28,7 +28,6 @@ import {
createVitestRunSpecs,
findUnmatchedExplicitTestTargets,
formatFailedShardDigest,
formatNoChangedTestTargetLines,
listFullExtensionVitestProjectConfigs,
orderFullSuiteSpecsForParallelRun,
parseTestProjectsArgs,
@@ -183,9 +182,21 @@ function isFullExtensionsProjectRun(specs) {
function printNoChangedTestTargets(args, cwd, baseEnv) {
const plan = resolveChangedTestTargetPlanForArgs(args, cwd, undefined, { env: baseEnv });
const skippedBroadFallbackPaths = plan?.skippedBroadFallbackPaths ?? [];
for (const line of formatNoChangedTestTargetLines(skippedBroadFallbackPaths)) {
console.error(line);
if (skippedBroadFallbackPaths.length === 0) {
console.error("[test] no changed test targets; skipping Vitest.");
return;
}
console.error("[test] no precise changed test targets; skipping Vitest.");
console.error(
`[test] ${skippedBroadFallbackPaths.length} changed path${
skippedBroadFallbackPaths.length === 1 ? "" : "s"
} require broad Vitest fallback:`,
);
for (const changedPath of skippedBroadFallbackPaths) {
console.error(`[test] ${changedPath}`);
}
console.error("[test] run `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` for broad coverage.");
}
async function runVitestSpecsParallel(specs, concurrency) {

View File

@@ -746,6 +746,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/ci-changed-scope.mjs", ["src/scripts/ci-changed-scope.test.ts"]],
["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]],
["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]],
["scripts/native-app-i18n.ts", ["test/scripts/native-app-i18n.test.ts"]],
[
"scripts/copy-bundled-plugin-metadata.mjs",
["src/plugins/copy-bundled-plugin-metadata.test.ts", "src/infra/run-node.test.ts"],
@@ -926,10 +927,6 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["test/scripts/docker-e2e-seeds.test.ts", "test/scripts/mcp-code-mode-gateway-client.test.ts"],
],
["scripts/e2e/mcp-client-temp-state.ts", ["test/scripts/mcp-channels-harness.test.ts"]],
[
"scripts/e2e/cron-cli-docker.sh",
["test/scripts/docker-build-helper.test.ts", "test/scripts/docker-e2e-observability.test.ts"],
],
[
"scripts/e2e/cron-mcp-cleanup-docker.sh",
[
@@ -2160,22 +2157,6 @@ export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS = String(900_000)
export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_HEARTBEAT_MS = String(
DEFAULT_VITEST_NO_OUTPUT_HEARTBEAT_MS,
);
export function formatNoChangedTestTargetLines(skippedBroadFallbackPaths) {
if (skippedBroadFallbackPaths.length === 0) {
return ["[test] no changed test targets; skipping Vitest."];
}
return [
"[test] no precise changed test targets; skipping Vitest.",
`[test] ${skippedBroadFallbackPaths.length} changed path${
skippedBroadFallbackPaths.length === 1 ? "" : "s"
} require broad Vitest fallback:`,
...skippedBroadFallbackPaths.map((changedPath) => `[test] ${changedPath}`),
"[test] run `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` for broad coverage.",
];
}
const EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD = 12;
const GATEWAY_SERVER_FULL_SUITE_TARGET_CHUNK_COUNT = 4;
const GATEWAY_SERVER_BACKED_HTTP_TEST_TARGETS = new Set([

View File

@@ -5,8 +5,8 @@
*/
import { copyPluginToolMeta } from "../plugins/tools.js";
import { bindAbortRelay } from "../utils/fetch-timeout.js";
import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js";
import type { AnyAgentTool } from "./agent-tools.types.js";
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
function throwAbortError(): never {

View File

@@ -73,18 +73,6 @@ export {
consumePreExecutionBlockedToolCall,
peekAdjustedParamsForToolCall,
} from "./agent-tools.before-tool-call.state.js";
import {
BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS,
BEFORE_TOOL_CALL_HOOK_CONTEXT,
BEFORE_TOOL_CALL_SOURCE_TOOL,
BEFORE_TOOL_CALL_WRAPPED,
type BeforeToolCallDiagnosticOptions,
} from "./before-tool-call-metadata.js";
export {
copyBeforeToolCallHookMarker,
isToolWrappedWithBeforeToolCallHook,
setBeforeToolCallDiagnosticsEnabled,
} from "./before-tool-call-metadata.js";
import { copyChannelAgentToolMeta, getChannelAgentToolMeta } from "./channel-tools.js";
import {
getCodeModeExecBeforeHookMetadata,
@@ -95,7 +83,6 @@ import {
} from "./code-mode-control-tools.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { normalizeToolName } from "./tool-policy.js";
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";
import { getToolTerminalPresentation } from "./tool-terminal-presentation.js";
import type { AnyAgentTool } from "./tools/common.js";
import { callGatewayTool } from "./tools/gateway.js";
@@ -225,6 +212,10 @@ export function hasBeforeToolCallPolicy(): boolean {
}
const log = createSubsystemLogger("agents/tools");
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions");
const BEFORE_TOOL_CALL_SOURCE_TOOL = Symbol("beforeToolCallSourceTool");
const BEFORE_TOOL_CALL_HOOK_CONTEXT = Symbol("beforeToolCallHookContext");
const BEFORE_TOOL_CALL_HOOK_FAILURE_REASON =
"Tool call blocked because before_tool_call hook failed";
const MAX_TRACKED_ADJUSTED_PARAMS = 1024;
@@ -1567,13 +1558,12 @@ export function wrapToolWithBeforeToolCallHook(
};
copyPluginToolMeta(tool, wrappedTool);
copyChannelAgentToolMeta(tool as never, wrappedTool as never);
copyToolTerminalPresentation(tool, wrappedTool);
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, {
value: true,
enumerable: true,
});
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS, {
value: hookOptions satisfies BeforeToolCallDiagnosticOptions,
value: hookOptions,
enumerable: false,
});
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_SOURCE_TOOL, {
@@ -1587,6 +1577,21 @@ export function wrapToolWithBeforeToolCallHook(
return wrappedTool;
}
/** Return true when a tool already carries the before_tool_call wrapper marker. */
export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean {
const taggedTool = tool as unknown as Record<symbol, unknown>;
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
}
/** Toggle diagnostic event emission on an existing before_tool_call wrapper. */
export function setBeforeToolCallDiagnosticsEnabled(tool: AnyAgentTool, enabled: boolean): void {
const taggedTool = tool as unknown as Record<symbol, unknown>;
const options = taggedTool[BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS];
if (options && typeof options === "object" && "emitDiagnostics" in options) {
(options as { emitDiagnostics: boolean }).emitDiagnostics = enabled;
}
}
/** Rebuild a before_tool_call wrapper while preserving the original source tool. */
export function rewrapToolWithBeforeToolCallHook(
tool: AnyAgentTool,
@@ -1613,10 +1618,33 @@ export function rewrapToolWithBeforeToolCallHook(
delete (rewrapSource as unknown as Record<symbol, unknown>)[BEFORE_TOOL_CALL_WRAPPED];
copyPluginToolMeta(tool, rewrapSource);
copyChannelAgentToolMeta(tool as never, rewrapSource as never);
copyToolTerminalPresentation(tool, rewrapSource);
return wrapToolWithBeforeToolCallHook(rewrapSource, ctx ?? preservedContext, options);
}
/** Copy before_tool_call marker metadata when another wrapper replaces a tool. */
export function copyBeforeToolCallHookMarker(source: AnyAgentTool, target: AnyAgentTool): void {
if (!isToolWrappedWithBeforeToolCallHook(source)) {
return;
}
Object.defineProperty(target, BEFORE_TOOL_CALL_WRAPPED, {
value: true,
enumerable: true,
});
const taggedSource = source as unknown as Record<symbol, unknown>;
const sourceTool = taggedSource[BEFORE_TOOL_CALL_SOURCE_TOOL];
if (sourceTool && typeof sourceTool === "object") {
Object.defineProperty(target, BEFORE_TOOL_CALL_SOURCE_TOOL, {
value: sourceTool,
enumerable: false,
});
}
const hookContext = taggedSource[BEFORE_TOOL_CALL_HOOK_CONTEXT];
Object.defineProperty(target, BEFORE_TOOL_CALL_HOOK_CONTEXT, {
value: hookContext,
enumerable: false,
});
}
function recordPreExecutionBlockedToolCall(toolCallId?: string, runId?: string): void {
if (!toolCallId) {
return;

View File

@@ -1,4 +1,5 @@
import { copyPluginToolMeta } from "../plugins/tools.js";
import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js";
/**
* Adjusts exec/process tool descriptions for long-running follow-up behavior.
* Cron-aware runs can point models at scheduled follow-ups; cronless runs keep
@@ -6,7 +7,6 @@ import { copyPluginToolMeta } from "../plugins/tools.js";
*/
import type { AnyAgentTool } from "./agent-tools.types.js";
import { describeExecTool, describeProcessTool } from "./bash-tools.descriptions.js";
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";

View File

@@ -8,8 +8,8 @@ import {
normalizeToolParameterSchema,
type ToolParameterSchemaOptions,
} from "./agent-tools-parameter-schema.js";
import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js";
import type { AnyAgentTool } from "./agent-tools.types.js";
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";

View File

@@ -1,49 +0,0 @@
import type { AnyAgentTool } from "./tools/common.js";
export type BeforeToolCallDiagnosticOptions = {
emitDiagnostics: boolean;
};
export const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
export const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions");
export const BEFORE_TOOL_CALL_SOURCE_TOOL = Symbol("beforeToolCallSourceTool");
export const BEFORE_TOOL_CALL_HOOK_CONTEXT = Symbol("beforeToolCallHookContext");
/** Return true when a tool already carries the before_tool_call wrapper marker. */
export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean {
const taggedTool = tool as unknown as Record<symbol, unknown>;
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
}
/** Toggle diagnostic event emission on an existing before_tool_call wrapper. */
export function setBeforeToolCallDiagnosticsEnabled(tool: AnyAgentTool, enabled: boolean): void {
const taggedTool = tool as unknown as Record<symbol, unknown>;
const options = taggedTool[BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS];
if (options && typeof options === "object" && "emitDiagnostics" in options) {
(options as BeforeToolCallDiagnosticOptions).emitDiagnostics = enabled;
}
}
/** Copy before_tool_call marker metadata when another wrapper replaces a tool. */
export function copyBeforeToolCallHookMarker(source: AnyAgentTool, target: AnyAgentTool): void {
if (!isToolWrappedWithBeforeToolCallHook(source)) {
return;
}
Object.defineProperty(target, BEFORE_TOOL_CALL_WRAPPED, {
value: true,
enumerable: true,
});
const taggedSource = source as unknown as Record<symbol, unknown>;
const sourceTool = taggedSource[BEFORE_TOOL_CALL_SOURCE_TOOL];
if (sourceTool && typeof sourceTool === "object") {
Object.defineProperty(target, BEFORE_TOOL_CALL_SOURCE_TOOL, {
value: sourceTool,
enumerable: false,
});
}
const hookContext = taggedSource[BEFORE_TOOL_CALL_HOOK_CONTEXT];
Object.defineProperty(target, BEFORE_TOOL_CALL_HOOK_CONTEXT, {
value: hookContext,
enumerable: false,
});
}

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { collectDeliveredMediaUrls } from "./delivery-evidence.js";
describe("collectDeliveredMediaUrls attachment recursion", () => {
it("collects media URLs across nested attachments", () => {
const urls = collectDeliveredMediaUrls({
payloads: [
{
url: "https://example.com/root.png",
attachments: [
{ mediaUrl: "https://example.com/child.png" },
{ attachments: [{ filePath: "/tmp/grandchild.jpg" }] },
],
},
],
});
expect(urls.toSorted()).toEqual([
"/tmp/grandchild.jpg",
"https://example.com/child.png",
"https://example.com/root.png",
]);
});
it("does not overflow the stack on a self-referential attachments cycle", () => {
// Payloads arrive as in-process `unknown` objects; a malformed self-referential
// attachments chain previously recursed until the stack overflowed.
const cyclic: Record<string, unknown> = { url: "https://example.com/loop.png" };
cyclic.attachments = [cyclic];
let urls: string[] = [];
expect(() => {
urls = collectDeliveredMediaUrls({ payloads: [cyclic] });
}).not.toThrow();
expect(urls).toEqual(["https://example.com/loop.png"]);
});
it("does not overflow on a mutual attachments cycle", () => {
const a: Record<string, unknown> = { mediaUrl: "https://example.com/a.png" };
const b: Record<string, unknown> = { mediaUrl: "https://example.com/b.png" };
a.attachments = [b];
b.attachments = [a];
const urls = collectDeliveredMediaUrls({ payloads: [a] });
expect(urls.toSorted()).toEqual(["https://example.com/a.png", "https://example.com/b.png"]);
});
});

View File

@@ -80,19 +80,7 @@ function collectStringValues(value: unknown, output: Set<string>) {
}
}
function collectMediaUrlsFromRecord(
record: Record<string, unknown>,
output: Set<string>,
// Payloads arrive as in-process `unknown` objects, so a malformed
// self-referential `attachments` chain would recurse until the stack
// overflows. Track visited records to bound the descent, matching
// redactStringsDeep in embedded-agent-subscribe.tools.ts.
seen = new WeakSet<object>(),
) {
if (seen.has(record)) {
return;
}
seen.add(record);
function collectMediaUrlsFromRecord(record: Record<string, unknown>, output: Set<string>) {
collectStringValues(record.mediaUrl, output);
collectStringValues(record.mediaUrls, output);
collectStringValues(record.path, output);
@@ -102,7 +90,7 @@ function collectMediaUrlsFromRecord(
if (Array.isArray(attachments)) {
for (const attachment of attachments) {
if (attachment && typeof attachment === "object" && !Array.isArray(attachment)) {
collectMediaUrlsFromRecord(attachment as Record<string, unknown>, output, seen);
collectMediaUrlsFromRecord(attachment as Record<string, unknown>, output);
}
}
}

View File

@@ -44,7 +44,6 @@ function createDeepSeekCompletionsModel(): Model<"openai-completions"> {
api: "openai-completions",
provider: "deepseek",
baseUrl: "https://api.deepseek.com",
compat: { thinkingFormat: "deepseek" },
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -1469,7 +1468,7 @@ describe("openai transport stream", () => {
name: "qwen-coder-plus",
api: "openai-completions",
provider: "qwen",
baseUrl: "",
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -2803,165 +2802,6 @@ describe("openai transport stream", () => {
expect(JSON.stringify(events)).not.toContain("DSML");
});
it.each([
{ finishReason: "length", stopReason: "length" },
{ finishReason: "content_filter", stopReason: "error" },
])(
"does not authorize recovered DeepSeek DSML calls after $finishReason",
async ({ finishReason, stopReason }) => {
const model = createDeepSeekCompletionsModel();
const output = createAssistantOutput(model);
expect(testing.getCompat(model).thinkingFormat).toBe("deepseek");
await testing.processOpenAICompletionsStream(
streamChunks([
{
id: "chatcmpl-deepseek-dsml-terminal",
object: "chat.completion.chunk",
created: 1,
model: model.id,
choices: [
{
index: 0,
delta: {
content:
'<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/partial.md"}</|DSML|invoke></|DSML|tool_calls>',
},
logprobs: null,
finish_reason: finishReason,
},
],
},
]),
output,
model,
{ push() {} },
);
expect(output.stopReason).toBe(stopReason);
expect(output.content).toEqual([]);
},
);
it("does not authorize recovered DeepSeek DSML calls when the stream omits a terminal", async () => {
const model = createDeepSeekCompletionsModel();
const output = createAssistantOutput(model);
await testing.processOpenAICompletionsStream(
streamChunks([
{
id: "chatcmpl-deepseek-dsml-no-terminal",
object: "chat.completion.chunk",
created: 1,
model: model.id,
choices: [
{
index: 0,
delta: {
content:
'<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/partial.md"}</|DSML|invoke></|DSML|tool_calls>',
},
logprobs: null,
finish_reason: null,
},
],
},
]),
output,
model,
{ push() {} },
);
expect(output.stopReason).toBe("stop");
expect(output.content).toEqual([]);
});
it("emits recovered DeepSeek content-filter terminals as errors", async () => {
const server = createServer((req, res) => {
req.resume();
req.on("end", () => {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
});
res.write(
`data: ${JSON.stringify({
id: "chatcmpl-deepseek-dsml-content-filter",
object: "chat.completion.chunk",
created: 1,
model: "deepseek-v4-pro",
choices: [
{
index: 0,
delta: {
content:
'<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/partial.md"}</|DSML|invoke></|DSML|tool_calls>',
},
finish_reason: "content_filter",
},
],
})}\n\n`,
);
res.end("data: [DONE]\n\n");
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Missing loopback server address");
}
const model = {
...createDeepSeekCompletionsModel(),
baseUrl: `http://127.0.0.1:${address.port}/v1`,
} satisfies Model<"openai-completions">;
const stream = createOpenAICompletionsTransportStreamFn()(
model,
{
systemPrompt: "system",
messages: [{ role: "user", content: "Read the file", timestamp: Date.now() }],
tools: [],
} as never,
{ apiKey: "test-key" } as never,
);
const terminalEvents: Array<{
type: string;
reason?: string;
error?: Record<string, unknown>;
}> = [];
for await (const event of stream as AsyncIterable<{
type: string;
reason?: string;
error?: Record<string, unknown>;
}>) {
if (event.type === "done" || event.type === "error") {
terminalEvents.push(event);
}
}
expect(terminalEvents).toEqual([
expect.objectContaining({
type: "error",
reason: "error",
error: expect.objectContaining({
stopReason: "error",
errorMessage: "Provider finish_reason: content_filter",
content: [],
}),
}),
]);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it("parses repeated DeepSeek DSML name attributes consistently", async () => {
// Guards the cached attribute matchers: repeated parses must stay identical
// (no stale RegExp lastIndex) across separate stream invocations.
@@ -6876,7 +6716,6 @@ describe("openai transport stream", () => {
api: "openai-completions",
provider: "qwen",
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
compat: { supportsUsageInStreaming: true },
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -6906,7 +6745,6 @@ describe("openai transport stream", () => {
api: "openai-completions",
provider: "generic",
baseUrl: "https://coding.dashscope.aliyuncs.com/v1",
compat: { supportsUsageInStreaming: true },
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },

View File

@@ -98,8 +98,6 @@ import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.j
import { transformTransportMessages } from "./transport-message-transform.js";
import {
assignTransportErrorDetails,
failTransportStream,
finalizeTransportStream,
mergeTransportMetadata,
sanitizeTransportPayloadText,
} from "./transport-stream-shared.js";
@@ -2803,9 +2801,15 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn {
signal: options?.signal,
emitReasoning,
});
finalizeTransportStream({ stream, output, signal: options?.signal });
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
stream.push({ type: "done", reason: output.stopReason as never, message: output as never });
stream.end();
} catch (error) {
failTransportStream({ stream, output, signal: options?.signal, error });
assignTransportErrorDetails(output, error, options?.signal);
stream.push({ type: "error", reason: output.stopReason as never, error: output as never });
stream.end();
}
})();
return eventStream as unknown as ReturnType<StreamFn>;
@@ -2972,6 +2976,7 @@ async function processOpenAICompletionsStream(
currentBlock = null;
flushPendingPostToolCallDeltas();
}
output.stopReason = "toolUse";
recoveredDeepSeekToolCallIndex += 1;
const block: ToolCallBlock = {
type: "toolCall",
@@ -3241,8 +3246,6 @@ async function processOpenAICompletionsStream(
if (output.stopReason === "toolUse" && !hasToolCalls) {
output.stopReason = "stop";
}
// Tool-call recovery is executable only after an explicit provider terminal.
// EOF alone can mean transport truncation, even when the recovered call parses.
if (sawStopFinishReason && output.stopReason === "stop" && hasToolCalls && !hasVisibleText) {
output.stopReason = "toolUse";
}

View File

@@ -1,54 +0,0 @@
// Verifies plugin tools inherit the agent Gateway caller identity from tool assembly.
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
observedIdentities: [] as Array<unknown>,
}));
vi.mock("./openclaw-plugin-tools.js", () => ({
resolveOpenClawPluginToolsForOptions: () => [
{
name: "synthetic_direct_cron_plugin",
label: "Synthetic direct cron plugin",
description: "Calls Gateway cron directly like plugin-owned reminder tools.",
parameters: { type: "object", properties: {} },
execute: async () => {
const { getGatewayToolCallerIdentity } = await import("./tools/gateway-caller-context.js");
mocks.observedIdentities.push(getGatewayToolCallerIdentity());
return { content: [{ type: "text", text: "ok" }] };
},
},
],
}));
import { createOpenClawTools } from "./openclaw-tools.js";
function requireTool(name: string) {
const tool = createOpenClawTools({
agentSessionKey: "agent:main:discord:channel:123",
disableMessageTool: true,
pluginToolAllowlist: [name],
requesterAgentIdOverride: "main",
wrapBeforeToolCallHook: false,
}).find((candidate) => candidate.name === name);
if (!tool?.execute) {
throw new Error(`Expected executable tool ${name}`);
}
return tool;
}
describe("createOpenClawTools Gateway caller identity", () => {
it("wraps plugin tools so direct cron Gateway calls inherit the agent identity", async () => {
mocks.observedIdentities.length = 0;
const tool = requireTool("synthetic_direct_cron_plugin");
await tool.execute("tool-call-1", {});
expect(mocks.observedIdentities).toEqual([
{
agentId: "main",
sessionKey: "agent:main:discord:channel:123",
},
]);
});
});

View File

@@ -43,7 +43,6 @@ import { createAgentsListTool } from "./tools/agents-list-tool.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool, type CronCreatorToolAllowlistEntry } from "./tools/cron-tool.js";
import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js";
import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-context.js";
import { createGatewayTool } from "./tools/gateway-tool.js";
import {
createCreateGoalTool,
@@ -577,17 +576,10 @@ export function createOpenClawTools(
options?.recordToolPrepStage?.("openclaw-tools:plugin-tools");
}
const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId;
const gatewayCallerIdentity =
hookAgentId && options?.agentSessionKey?.trim()
? { agentId: hookAgentId, sessionKey: options.agentSessionKey.trim() }
: undefined;
const wrapGatewayCallerIdentity = (tool: AnyAgentTool) =>
wrapToolWithGatewayCallerIdentity(tool, gatewayCallerIdentity);
if (options?.wrapBeforeToolCallHook === false) {
return allTools.map(wrapGatewayCallerIdentity);
return allTools;
}
const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId;
const defaultHookContext: HookContext = {
...(hookAgentId ? { agentId: hookAgentId } : {}),
...(resolvedConfig ? { config: resolvedConfig } : {}),
@@ -601,13 +593,11 @@ export function createOpenClawTools(
...options?.beforeToolCallHookContext,
};
options?.recordToolPrepStage?.("openclaw-tools:tool-hooks");
return allTools
.map((tool) =>
isToolWrappedWithBeforeToolCallHook(tool)
? tool
: wrapToolWithBeforeToolCallHook(tool, hookContext),
)
.map(wrapGatewayCallerIdentity);
return allTools.map((tool) =>
isToolWrappedWithBeforeToolCallHook(tool)
? tool
: wrapToolWithBeforeToolCallHook(tool, hookContext),
);
}
export const testing = {

View File

@@ -1130,37 +1130,6 @@ describe("buildGuardedModelFetch", () => {
expect(items).toEqual([{ ok: true }]);
});
it("handles a large transport chunk containing many valid small SSE events", async () => {
// Regression: one TCP read can deliver >64 KiB of already-delimited SSE
// events; the cap must apply only to the unterminated tail, not the full chunk.
const eventCount = 5_000;
const manyEvents = `data: ${JSON.stringify({ ok: true })}\n\n`.repeat(eventCount);
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(manyEvents, {
headers: { "content-type": "text/event-stream" },
}),
finalUrl: "https://openrouter.ai/api/v1/chat/completions",
release: vi.fn(async () => undefined),
});
const model = {
id: "gpt-5.4",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
} as unknown as Model<"openai-completions">;
const response = await buildGuardedModelFetch(model)(
"https://openrouter.ai/api/v1/chat/completions",
{ method: "POST" },
);
const items: unknown[] = [];
for await (const item of Stream.fromSSEResponse(response, new AbortController())) {
items.push(item);
}
expect(items.length).toBe(eventCount);
expect(items[0]).toEqual({ ok: true });
});
it("synthesizes SSE frames for JSON bodies returned to streaming OpenAI SDK requests", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(' {"ok": true} ', {
@@ -1369,102 +1338,6 @@ describe("buildGuardedModelFetch", () => {
expect(refreshTimeout).toHaveBeenCalledTimes(2);
});
it("errors on oversized SSE body without event boundary in sanitizer", async () => {
const oversized = "x".repeat(65 * 1024);
const encoder = new TextEncoder();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(oversized));
controller.close();
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
finalUrl: "https://openrouter.ai/api/v1/chat/completions",
release: vi.fn(async () => undefined),
});
const model = {
id: "gpt-5.4",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
} as unknown as Model<"openai-completions">;
const response = await buildGuardedModelFetch(model)(
"https://openrouter.ai/api/v1/chat/completions",
{ method: "POST" },
);
const reader = response.body?.getReader();
let caught: unknown = null;
try {
while (true) {
const { done } = await reader!.read();
if (done) {
break;
}
}
} catch (e) {
caught = e;
}
expect(caught).toBeTruthy();
expect(String(caught)).toMatch(/exceeded max buffer size/i);
});
it("errors on oversized streaming JSON body without content-length in SSE synthesis", async () => {
const CHUNK = 1024 * 1024;
let sends = 0;
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
new ReadableStream({
pull(controller) {
if (sends < 17) {
sends++;
controller.enqueue(new Uint8Array(CHUNK));
} else {
controller.close();
}
},
}),
{ headers: { "content-type": "application/json" } },
),
finalUrl: "https://openrouter.ai/api/v1/chat/completions",
release: vi.fn(async () => undefined),
});
const model = {
id: "moonshotai/kimi-k2.6",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
} as unknown as Model<"openai-completions">;
const response = await buildGuardedModelFetch(model)(
"https://openrouter.ai/api/v1/chat/completions",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "moonshotai/kimi-k2.6", stream: true }),
},
);
const reader = response.body?.getReader();
let caught: unknown = null;
try {
while (true) {
const { done } = await reader!.read();
if (done) {
break;
}
}
} catch (e) {
caught = e;
}
expect(caught).toBeTruthy();
expect(String(caught)).toMatch(/exceeded.*bytes while synthesizing SSE/i);
});
describe("long retry-after handling", () => {
const anthropicModel = {
id: "sonnet-4.6",

View File

@@ -45,17 +45,6 @@ import {
const DEFAULT_MAX_SDK_RETRY_WAIT_SECONDS = 60;
const OPENAI_SDK_STREAM_CONTENT_SNIFF_BYTES = 2 * 1024;
const log = createSubsystemLogger("provider-transport-fetch");
/** Max bytes for an entire JSON body synthesized into SSE frames. Prevents OOM
* when a hostile streaming endpoint returns a never-ending JSON response
* without Content-Length. */
const SSE_SYNTHESIZE_JSON_MAX_BYTES = 16 * 1024 * 1024;
/** Max bytes for the internal SSE sanitization buffer between event boundaries.
* A response that cannot find a \n\n boundary within this many characters is
* almost certainly hostile or broken — cap the buffer rather than let it grow. */
const SSE_SANITIZE_BUFFER_MAX_BYTES = 64 * 1024;
const BLOCKED_EXACT_ORIGIN_TRUST_HOSTNAME_LABELS = new Set(["instance-data"]);
const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/;
const RETRY_AFTER_HTTP_DATE_RE =
@@ -113,7 +102,6 @@ function sanitizeOpenAISdkSseResponse(
const encoder = new TextEncoder();
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
let buffer = "";
let totalBytes = 0;
const sseBody = new ReadableStream<Uint8Array>({
start() {
reader = source.getReader();
@@ -132,17 +120,9 @@ function sanitizeOpenAISdkSseResponse(
controller.close();
return;
}
const nextTotalBytes = totalBytes + chunk.value.byteLength;
if (nextTotalBytes > SSE_SYNTHESIZE_JSON_MAX_BYTES) {
throw new Error(
`Streaming JSON body exceeded ${SSE_SYNTHESIZE_JSON_MAX_BYTES} bytes while synthesizing SSE frames`,
);
}
totalBytes = nextTotalBytes;
buffer += decoder.decode(chunk.value, { stream: true });
}
} catch (error) {
await reader?.cancel(error).catch(() => {});
controller.error(error);
}
},
@@ -177,11 +157,6 @@ function sanitizeOpenAISdkSseResponse(
for (;;) {
const boundary = findSseEventBoundary(buffer);
if (!boundary) {
if (buffer.length > SSE_SANITIZE_BUFFER_MAX_BYTES) {
throw new Error(
`SSE response exceeded max buffer size (${SSE_SANITIZE_BUFFER_MAX_BYTES} bytes) without event boundary`,
);
}
return enqueued;
}
const block = buffer.slice(0, boundary.index);
@@ -192,7 +167,6 @@ function sanitizeOpenAISdkSseResponse(
if (hasReadableSseData(block)) {
controller.enqueue(encoder.encode(`${block}${separator}`));
enqueued += 1;
return enqueued;
}
}
};
@@ -204,10 +178,6 @@ function sanitizeOpenAISdkSseResponse(
async pull(controller) {
try {
for (;;) {
const pending = enqueueSanitized(controller, "");
if (pending > 0) {
return;
}
const chunk = await reader?.read();
if (!chunk || chunk.done) {
const tail = decoder.decode();
@@ -230,7 +200,6 @@ function sanitizeOpenAISdkSseResponse(
}
}
} catch (error) {
await reader?.cancel(error).catch(() => {});
controller.error(error);
}
},

View File

@@ -8,7 +8,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ProviderRuntimePluginHandle } from "../../plugins/provider-hook-runtime.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import { copyPluginToolMeta } from "../../plugins/tools.js";
import { copyBeforeToolCallHookMarker } from "../before-tool-call-metadata.js";
import { copyBeforeToolCallHookMarker } from "../agent-tools.before-tool-call.js";
import { copyChannelAgentToolMeta } from "../channel-tools.js";
import {
logProviderToolSchemaDiagnostics,

View File

@@ -1,87 +0,0 @@
/**
* Bounded SSE / NDJSON stream reader guard.
*
* Wraps a `ReadableStreamDefaultReader<Uint8Array>` so the caller's existing
* chunk-by-chunk parsing logic is unchanged, but accumulated bytes are tracked
* against a hard cap. On overflow the underlying reader is cancelled and a
* canonical error is thrown. Mirrors the `readResponseWithLimit` / bounded
* JSON response pattern (see `src/agents/provider-http-errors.ts`).
*
* Internal helper for now. If extensions need it, promote to a plugin-SDK
* subpath in a separate, dedicated PR with full SDK metadata sync.
*/
export type SseStreamOverflow = {
size: number;
maxBytes: number;
};
export type ReadSseStreamWithLimitOptions = {
maxBytes: number;
onOverflow?: (params: SseStreamOverflow) => Error;
};
export type SseByteGuard = {
read(): Promise<ReadableStreamReadResult<Uint8Array>>;
cancel(reason?: unknown): Promise<void>;
totalBytes(): number;
overflowed(): boolean;
cancelled(): boolean;
};
export function createSseByteGuard(
reader: ReadableStreamDefaultReader<Uint8Array>,
opts: ReadSseStreamWithLimitOptions,
): SseByteGuard {
if (!Number.isFinite(opts.maxBytes) || opts.maxBytes < 0) {
throw new RangeError(`maxBytes must be a non-negative finite number: ${opts.maxBytes}`);
}
const onOverflow =
opts.onOverflow ??
((params) =>
new Error(`SSE stream exceeds ${params.maxBytes} bytes (received ${params.size})`));
let total = 0;
let overflowedFlag = false;
let cancelledFlag = false;
return {
read: async () => {
if (overflowedFlag || cancelledFlag) {
return { done: true, value: undefined };
}
const result = await reader.read();
if (result.done) {
return result;
}
const chunkLen = result.value?.byteLength ?? 0;
const next = total + chunkLen;
if (next > opts.maxBytes) {
overflowedFlag = true;
cancelledFlag = true;
const err = onOverflow({ size: next, maxBytes: opts.maxBytes });
try {
await reader.cancel(err);
} catch {
// best-effort cancellation; caller observes the overflow error
}
throw err;
}
total = next;
return result;
},
cancel: async (reason?: unknown) => {
if (overflowedFlag) {
// overflow already set cancelledFlag; do not overwrite
return;
}
cancelledFlag = true;
try {
await reader.cancel(reason);
} catch {
// best-effort cancellation
}
},
totalBytes: () => total,
overflowed: () => overflowedFlag,
cancelled: () => cancelledFlag,
};
}

View File

@@ -1,81 +0,0 @@
/**
* Regression coverage for surrogate-safe truncation in compact tool display
* detail coercion (coerceDisplayValue, reached via resolveToolVerbAndDetailForArgs
* -> resolveDetailFromKeys).
*/
import { describe, expect, it } from "vitest";
import { resolveToolVerbAndDetailForArgs } from "./tool-display-common.js";
function isHighSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
}
function isLowSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
}
function hasLoneSurrogate(value: string): boolean {
for (let i = 0; i < value.length; i += 1) {
const codeUnit = value.charCodeAt(i);
if (isHighSurrogate(codeUnit)) {
if (i + 1 >= value.length || !isLowSurrogate(value.charCodeAt(i + 1))) {
return true;
}
} else if (isLowSurrogate(codeUnit)) {
if (i === 0 || !isHighSurrogate(value.charCodeAt(i - 1))) {
return true;
}
}
}
return false;
}
describe("coerceDisplayValue surrogate-safe truncation", () => {
it("does not split an emoji across the truncation boundary (default maxStringChars=160)", () => {
// 200 UTF-16 units: 78 'a', an emoji (surrogate pair at indices 78-79), 120 'b'.
// With maxStringChars=160, half = floor(159/2) = 79, so the naive
// firstLine.slice(0, 79) keeps only the emoji's high surrogate at index 78.
const detailValue = `${"a".repeat(78)}\u{1F600}${"b".repeat(120)}`;
expect(detailValue.length).toBe(200);
const { detail } = resolveToolVerbAndDetailForArgs({
toolKey: "custom_tool",
args: { note: detailValue },
fallbackDetailKeys: ["note"],
detailMode: "first",
});
expect(detail).toBeDefined();
// The bug rendered a lone high surrogate (and possibly a lone low surrogate
// at the tail head); the fix must drop the whole emoji at the cut.
expect(hasLoneSurrogate(detail as string)).toBe(false);
// Head keeps only the 78 leading 'a's (emoji dropped, not half-kept).
expect((detail as string).split("…")[0]).toBe("a".repeat(78));
// Tail must not begin mid-pair on a lone low surrogate.
const tail = (detail as string).split("…")[1] ?? "";
expect(isLowSurrogate(tail.charCodeAt(0))).toBe(false);
});
it("leaves plain (non-surrogate) long values truncated as before", () => {
const detailValue = "x".repeat(300);
const { detail } = resolveToolVerbAndDetailForArgs({
toolKey: "custom_tool",
args: { note: detailValue },
fallbackDetailKeys: ["note"],
detailMode: "first",
});
// Behavior-preserving for ASCII: half = 79, so 79 'x' + ellipsis + 80 'x'.
expect(detail).toBe(`${"x".repeat(79)}${"x".repeat(80)}`);
expect(hasLoneSurrogate(detail as string)).toBe(false);
});
it("returns short values unchanged", () => {
const { detail } = resolveToolVerbAndDetailForArgs({
toolKey: "custom_tool",
args: { note: "short value with no emoji" },
fallbackDetailKeys: ["note"],
detailMode: "first",
});
expect(detail).toBe("short value with no emoji");
});
});

View File

@@ -3,14 +3,15 @@
* Redacts and summarizes arguments into short labels/details for chat and UI
* tool update streams.
*/
import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import {
asOptionalObjectRecord as asRecord,
} from "@openclaw/normalization-core/record-coerce";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { parseStrictFiniteNumber } from "../infra/parse-finite-number.js";
import { redactToolPayloadText } from "../logging/redact.js";
import { sliceUtf16Safe } from "../shared/utf16-slice.js";
import { resolveExecDetail, type ToolDetailMode } from "./tool-display-exec.js";
type ToolDisplayActionSpec = {
@@ -135,7 +136,7 @@ function coerceDisplayValue(
const firstLine = redactToolPayloadText(rawLine);
if (firstLine.length > maxStringChars) {
const half = Math.floor((maxStringChars - 1) / 2);
return `${sliceUtf16Safe(firstLine, 0, half)}${sliceUtf16Safe(firstLine, -(maxStringChars - 1 - half))}`;
return `${firstLine.slice(0, half)}${firstLine.slice(-(maxStringChars - 1 - half))}`;
}
return firstLine;
}

View File

@@ -5,7 +5,6 @@
*/
import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { redactToolPayloadText } from "../logging/redact.js";
import { sliceUtf16Safe } from "../shared/utf16-slice.js";
import {
binaryName,
firstPositional,
@@ -443,7 +442,7 @@ function compactRawCommand(raw: string, maxLength = 120): string {
return oneLine;
}
const half = Math.floor((maxLength - 1) / 2);
return `${sliceUtf16Safe(oneLine, 0, half)}${sliceUtf16Safe(oneLine, -(maxLength - 1 - half))}`;
return `${oneLine.slice(0, half)}${oneLine.slice(-(maxLength - 1 - half))}`;
}
export type ToolDetailMode = "explain" | "raw";

View File

@@ -562,28 +562,6 @@ describe("compactRawCommand middle truncation", () => {
expect(result).not.toContain("AKIDABCDEFGHIJKLMNOP1234567890");
expect(result).toContain("AKIDAB…7890");
});
it("does not split a surrogate pair when the head boundary lands on an emoji", () => {
// The one-line form is 140 UTF-16 units. With the default maxLength=120 the head
// slice ends at index 59, but the 😀 emoji (U+1F600, a surrogate pair) occupies
// indices 58-59 — so a raw .slice(0, 59) would keep the high surrogate and drop
// its low half, leaving a lone surrogate that renders as the replacement char.
const emoji = String.fromCodePoint(0x1f600);
// Unknown binary so resolveExecDetail returns the compact raw form directly.
const longCommand = `/opt/custom/bin/run ${"a".repeat(38)}${emoji}${"b".repeat(80)}`;
const result = resolveExecDetail({ command: longCommand });
expect(result).toBeDefined();
// The whole emoji is dropped at the boundary rather than half of it.
expect(result).not.toContain(emoji);
// No dangling/lone surrogate code units remain in the rendered detail.
expect(result).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
expect(result).not.toMatch(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/);
// Start and end of the command are still preserved around the ellipsis.
expect(result).toContain("/opt/custom/bin/run");
expect(result).toContain("…");
expect(result).toMatch(/b{4}$/);
});
});
describe("coerceDisplayValue middle truncation", () => {

View File

@@ -6,13 +6,9 @@ const { callGatewayToolMock } = vi.hoisted(() => ({
callGatewayToolMock: vi.fn(),
}));
vi.mock("../agent-scope.js", async () => {
const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js");
return {
...actual,
resolveSessionAgentId: actual.resolveSessionAgentId,
};
});
vi.mock("../agent-scope.js", () => ({
resolveSessionAgentId: () => "agent-123",
}));
import { getToolTerminalPresentation } from "../tool-terminal-presentation.js";
import { createCronTool } from "./cron-tool.js";

View File

@@ -11,7 +11,7 @@ vi.mock("../agent-scope.js", async () => {
const actual = await vi.importActual<typeof import("../agent-scope.js")>("../agent-scope.js");
return {
...actual,
resolveSessionAgentId: actual.resolveSessionAgentId,
resolveSessionAgentId: () => "agent-123",
};
});
@@ -182,10 +182,7 @@ describe("cron tool", () => {
it("allows scoped isolated cron runs to remove the current job", async () => {
// Self-removal scope lets a cron-triggered run clean up its own schedule
// without granting broad cron mutation access.
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
await tool.execute("call-self-remove", {
action: "remove",
@@ -197,10 +194,7 @@ describe("cron tool", () => {
});
it("denies scoped isolated cron runs from removing another job", async () => {
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
await expect(
tool.execute("call-remove-other", {
@@ -221,10 +215,7 @@ describe("cron tool", () => {
hasMore: false,
nextOffset: null,
});
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
const result = await tool.execute("call-self-runs", {
action: "runs",
@@ -247,10 +238,7 @@ describe("cron tool", () => {
["another job", { action: "runs", jobId: "job-other" }],
["missing job id", { action: "runs" }],
])("denies scoped isolated cron runs from reading %s run history", async (_label, args) => {
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
await expect(tool.execute("call-runs-denied", args)).rejects.toThrow(
"Cron tool is restricted to the current cron job.",
@@ -293,10 +281,7 @@ describe("cron tool", () => {
it("allows scoped isolated cron runs to get the current job", async () => {
callGatewayMock.mockResolvedValueOnce({ id: "job-current", name: "current" });
const tool = createTestCronTool({
agentSessionKey: "main",
selfRemoveOnlyJobId: "job-current",
});
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
const result = await tool.execute("call-get", {
action: "get",
@@ -344,6 +329,7 @@ describe("cron tool", () => {
const result = await tool.execute("call-list", {
action: "list",
agentId: "other-agent",
includeDisabled: true,
});
@@ -462,44 +448,22 @@ describe("cron tool", () => {
});
const params = expectSingleGatewayCallMethod("cron.list");
expect(params).toEqual({
includeDisabled: false,
compact: true,
agentId: "agent-123",
});
expect(params).toEqual({ includeDisabled: false, compact: true, agentId: "agent-123" });
});
it("rejects explicit cron list agent id outside the requester session", async () => {
it("prefers explicit cron list agent id over the requester session", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-list-explicit", {
action: "list",
agentId: "ops",
includeDisabled: true,
}),
).rejects.toThrow("cron list agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("preserves explicit agentId for sessionless cron list callers", async () => {
const tool = createTestCronTool();
await tool.execute("call-sessionless-list", {
await tool.execute("call-list-explicit", {
action: "list",
agentId: "worker",
agentId: "ops",
includeDisabled: true,
});
const params = expectSingleGatewayCallMethod("cron.list");
expect(params).toEqual({
includeDisabled: true,
compact: true,
agentId: "worker",
});
expect(params).toEqual({ includeDisabled: true, compact: true, agentId: "ops" });
});
it("retries cron.list without compact for older gateways", async () => {
@@ -519,18 +483,11 @@ describe("cron tool", () => {
expect(readGatewayCall(0)).toEqual({
method: "cron.list",
params: {
includeDisabled: false,
compact: true,
agentId: "agent-123",
},
params: { includeDisabled: false, compact: true, agentId: "agent-123" },
});
expect(readGatewayCall(1)).toEqual({
method: "cron.list",
params: {
includeDisabled: false,
agentId: "agent-123",
},
params: { includeDisabled: false, agentId: "agent-123" },
});
});
@@ -787,10 +744,7 @@ describe("cron tool", () => {
id: "job-legacy",
});
expect(readGatewayCall().params).toEqual({
id: "job-primary",
mode: "due",
});
expect(readGatewayCall().params).toEqual({ id: "job-primary", mode: "due" });
});
it("supports due-only run mode", async () => {
@@ -801,10 +755,7 @@ describe("cron tool", () => {
runMode: "due",
});
expect(readGatewayCall().params).toEqual({
id: "job-due",
mode: "due",
});
expect(readGatewayCall().params).toEqual({ id: "job-due", mode: "due" });
});
it("supports force run mode", async () => {
@@ -815,10 +766,7 @@ describe("cron tool", () => {
runMode: "force",
});
expect(readGatewayCall().params).toEqual({
id: "job-force",
mode: "force",
});
expect(readGatewayCall().params).toEqual({ id: "job-force", mode: "force" });
});
it("normalizes cron.add job payloads", async () => {
@@ -846,43 +794,18 @@ describe("cron tool", () => {
});
});
it("rejects null agentId on add from the scoped agent cron tool", async () => {
it("does not default agentId when job.agentId is null", async () => {
const tool = createTestCronTool({ agentSessionKey: "main" });
await expect(
tool.execute("call-null", {
action: "add",
job: {
name: "wake-up",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "hello" },
agentId: null,
},
}),
).rejects.toThrow("cron job agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("preserves explicit agentId for sessionless cron add callers", async () => {
const tool = createTestCronTool();
await tool.execute("call-sessionless-add", {
await tool.execute("call-null", {
action: "add",
job: {
name: "worker job",
name: "wake-up",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
agentId: "worker",
agentId: null,
},
});
const params = expectSingleGatewayCallMethod("cron.add");
expect(params).toMatchObject({
name: "worker job",
agentId: "worker",
payload: { kind: "agentTurn", message: "hello" },
});
expect(params).not.toHaveProperty("callerScope");
expect(readGatewayCall().params?.agentId).toBeNull();
});
it("infers session agentId when job.agentId is omitted", async () => {
@@ -905,71 +828,6 @@ describe("cron tool", () => {
).resolves.toBe("agent-123");
});
it("accepts matching explicit agentId on add", async () => {
await expect(
executeAddAndReadAgentId({
callId: "call-matching-agent-id",
agentSessionKey: "agent:agent-123:telegram:direct:channing",
includeAgentId: true,
agentId: "agent-123",
}),
).resolves.toBe("agent-123");
});
it("rejects foreign explicit agentId on add", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-foreign-agent-id", {
action: "add",
job: {
name: "foreign",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
agentId: "worker",
},
}),
).rejects.toThrow("cron job agentId must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects foreign agent-prefixed session refs on add", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-foreign-session-ref", {
action: "add",
job: {
name: "foreign session",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
sessionTarget: "session:agent:worker:telegram:direct:alice",
},
}),
).rejects.toThrow("cron sessionTarget must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("does not forward model-supplied callerScope", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-spoofed-caller-scope", {
action: "remove",
jobId: "job-1",
callerScope: { kind: "agentTool", agentId: "worker" },
});
expect(readGatewayCall().params).toEqual({
id: "job-1",
});
});
it("passes through failureAlert=false for add", async () => {
const tool = createTestCronTool();
await tool.execute("call-disable-alerts-add", {
@@ -1373,23 +1231,23 @@ describe("cron tool", () => {
expect(text).not.toContain("Recent context:");
});
it("rejects explicit agentId null on add", async () => {
it("preserves explicit agentId null on add", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({ agentSessionKey: "main" });
await expect(
tool.execute("call6", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
agentId: null,
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
}),
).rejects.toThrow("cron job agentId must match the calling agent");
await tool.execute("call6", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
agentId: null,
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
expect(callGatewayMock).not.toHaveBeenCalled();
const call = readGatewayCall();
expect(call.method).toBe("cron.add");
expect(call.params?.agentId).toBeNull();
});
it("does not infer delivery from raw session-key fragments without delivery context", async () => {
@@ -1909,55 +1767,6 @@ describe("cron tool", () => {
});
});
it("rejects agentId retargeting on update", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-update-agent-id", {
action: "update",
id: "job-1",
patch: { agentId: "worker" },
}),
).rejects.toThrow("cron patch agentId cannot be changed");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("allows unscoped operator cron.update agentId retargeting", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-unscoped-update-agent-id", {
action: "update",
id: "job-1",
patch: { agentId: "worker" },
});
const params = expectSingleGatewayCallMethod("cron.update") as
| { id?: string; patch?: { agentId?: string } }
| undefined;
expect(params).toEqual({
id: "job-1",
patch: { agentId: "worker" },
});
});
it("rejects foreign sessionTarget retargeting on update", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-update-session-target", {
action: "update",
id: "job-1",
patch: { sessionTarget: "session:agent:worker:telegram:direct:alice" },
}),
).rejects.toThrow("cron sessionTarget must match the calling agent");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("recovers additional flat patch params for update action", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });

Some files were not shown because too many files have changed in this diff Show More