mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 17:31:31 +08:00
Compare commits
7 Commits
fix/agent-
...
codex/mess
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5335b0743 | ||
|
|
9f84eaa087 | ||
|
|
97264cb7cb | ||
|
|
87d3d14ec8 | ||
|
|
dcb02431d6 | ||
|
|
d94d2c8b35 | ||
|
|
4291b6b7b9 |
@@ -1,2 +1,2 @@
|
||||
abdff20b710c6b0fecb5af25603d7cfad7ade80600ca374ebe38f69d78933b50 plugin-sdk-api-baseline.json
|
||||
630367961e4d14463020f588564c23308159ae2de6e4301418b2b0c471797e70 plugin-sdk-api-baseline.jsonl
|
||||
35b314075ff47453c5d57788861ca0c0e65d6a988b549ab2a2e1757b7590d140 plugin-sdk-api-baseline.json
|
||||
0dc8abcefccfe7d19280bde5fb2c0c69cf73b782d47e3759e2984baf904fe07c plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -399,17 +399,13 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
|
||||
<Accordion title="Resolving plugin id vs npm spec">
|
||||
When you pass a plugin id, OpenClaw reuses the recorded install spec for that plugin. That means previously stored dist-tags such as `@beta` and exact pinned versions continue to be used on later `update <id>` runs.
|
||||
|
||||
That targeted-update rule is different from the bulk `openclaw plugins update --all` maintenance path. Bulk updates still respect ordinary tracked install specs, but trusted official OpenClaw plugin records can sync to the current official catalog target instead of staying on a stale exact official package. Use targeted `update <id>` when you intentionally want to keep an exact or tagged official spec untouched.
|
||||
|
||||
For npm installs, you can also pass an explicit npm package spec with a dist-tag or exact version. OpenClaw resolves that package name back to the tracked plugin record, updates that installed plugin, and records the new npm spec for future id-based updates.
|
||||
|
||||
Passing the npm package name without a version or tag also resolves back to the tracked plugin record. Use this when a plugin was pinned to an exact version and you want to move it back to the registry's default release line.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Beta channel updates">
|
||||
Targeted `openclaw plugins update <id-or-npm-spec>` reuses the tracked plugin spec unless you pass a new spec. Bulk `openclaw plugins update --all` uses the configured `update.channel` when it syncs trusted official plugin records to the official catalog target, so beta-channel installs can stay on the beta release line instead of being silently normalized to stable/latest.
|
||||
|
||||
`openclaw update` also knows the active OpenClaw update channel: on the beta channel, default-line npm and ClawHub plugin records try `@beta` first. They fall back to the recorded default/latest spec if no plugin beta release exists; npm plugins also fall back when the beta package exists but fails install validation. That fallback is reported as a warning and does not fail the core update. Exact versions and explicit tags stay pinned to that selector for targeted updates.
|
||||
`openclaw plugins update` reuses the tracked plugin spec unless you pass a new spec. `openclaw update` additionally knows the active OpenClaw update channel: on the beta channel, default-line npm and ClawHub plugin records try `@beta` first. They fall back to the recorded default/latest spec if no plugin beta release exists; npm plugins also fall back when the beta package exists but fails install validation. That fallback is reported as a warning and does not fail the core update. Exact versions and explicit tags stay pinned to that selector.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Version checks and integrity drift">
|
||||
|
||||
@@ -167,7 +167,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism
|
||||
- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedAgent` abort timer.
|
||||
- Cron runtime: isolated agent-turn `timeoutSeconds` is owned by cron. The scheduler starts that timer when execution begins, aborts the underlying run at the configured deadline, then runs bounded cleanup before recording the timeout so a stale child session cannot keep the lane stuck.
|
||||
- Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; owned silent model calls also stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not reported as stalled too early. Active work with no recent progress reports as `session.stalled`; owned model calls switch to `session.stalled` at or after the abort threshold, and ownerless stale model/tool activity is not hidden as long-running. `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
|
||||
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered cloud model runs with no explicit model or agent timeout use the same default idle watchdog; with an explicit cron run timeout, cloud model stream stalls are capped at 60s so configured model fallbacks can run before the outer cron deadline. Cron-triggered local or self-hosted model runs disable the implicit watchdog unless an explicit timeout is configured, and explicit cron run timeouts remain the idle window for local/self-hosted providers, so slow local providers should set `models.providers.<id>.timeoutSeconds`.
|
||||
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered cloud model runs with no explicit model or agent timeout use the same default idle watchdog; cron-triggered local or self-hosted model runs disable the implicit watchdog unless an explicit timeout is configured, so slow local providers should set `models.providers.<id>.timeoutSeconds`.
|
||||
- Provider HTTP request timeout: `models.providers.<id>.timeoutSeconds` applies to that provider's model HTTP fetches, including connect, headers, body, SDK request timeout, total guarded-fetch abort handling, and model stream idle watchdog. Use this for slow local/self-hosted providers such as Ollama before raising the whole agent runtime timeout, and keep the agent/runtime timeout at least as high when the model request needs to run longer.
|
||||
|
||||
## Where things can end early
|
||||
|
||||
@@ -737,10 +737,6 @@ outbound host generic and use the messaging adapter surface for provider rules:
|
||||
should be treated as `direct`, `group`, or `channel` before directory lookup.
|
||||
- `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an
|
||||
input should skip straight to id-like resolution instead of directory search.
|
||||
- `messaging.targetResolver.reservedLiterals` lists bare words that are
|
||||
channel/session references for that provider. Resolution preserves configured
|
||||
directory entries before rejecting reserved literals, then fails closed on a
|
||||
directory miss.
|
||||
- `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when
|
||||
core needs a final provider-owned resolution after normalization or after a
|
||||
directory miss.
|
||||
|
||||
@@ -115,17 +115,6 @@ before the thread starts.
|
||||
After changing Computer Use config, use `/new` or `/reset` in the affected chat
|
||||
before testing if an existing Codex thread has already started.
|
||||
|
||||
On macOS managed stdio startup, OpenClaw prefers the signed desktop Codex app
|
||||
bundle at `/Applications/Codex.app/Contents/Resources/codex` when it exists.
|
||||
That keeps Computer Use under the app bundle that owns the local desktop-control
|
||||
permissions. If the desktop app is not installed, OpenClaw falls back to the
|
||||
managed Codex binary installed beside the plugin. If an installed desktop app
|
||||
initializes with an unsupported app-server version, OpenClaw closes that child
|
||||
and retries the next managed binary candidate instead of letting a stale
|
||||
desktop app shadow the plugin-local fallback. Explicit `appServer.command`
|
||||
config or `OPENCLAW_CODEX_APP_SERVER_BIN` still overrides this managed
|
||||
selection.
|
||||
|
||||
## Commands
|
||||
|
||||
Use the `/codex computer-use` commands from any chat surface where the `codex`
|
||||
@@ -287,13 +276,7 @@ Codex app-server MCP status, or macOS permissions.
|
||||
**Status or a probe times out on `computer-use.list_apps`.** The plugin and MCP
|
||||
server are present, but the local Computer Use bridge did not answer. Quit or
|
||||
restart Codex Computer Use, relaunch Codex Desktop if needed, then retry in a
|
||||
fresh OpenClaw session. If the host previously ran Computer Use through an older
|
||||
managed Codex app-server, refresh the installed plugin from the desktop bundled
|
||||
marketplace:
|
||||
|
||||
```text
|
||||
/codex computer-use install --source /Applications/Codex.app/Contents/Resources/plugins/openai-bundled
|
||||
```
|
||||
fresh OpenClaw session.
|
||||
|
||||
**A Computer Use tool says `Native hook relay unavailable`.** The Codex-native
|
||||
tool hook could not reach an active OpenClaw relay through the local bridge or
|
||||
|
||||
@@ -110,13 +110,6 @@ When you pass a plugin id, OpenClaw reuses the tracked install spec. Stored
|
||||
dist-tags such as `@beta` and exact pinned versions continue to be used on
|
||||
later `update <plugin-id>` runs.
|
||||
|
||||
`openclaw plugins update --all` is the bulk maintenance path. It still respects
|
||||
ordinary tracked install specs, but trusted official OpenClaw plugin records can
|
||||
sync to the current official catalog target instead of staying on a stale exact
|
||||
official package. If `update.channel` is set to `beta`, that bulk official sync
|
||||
uses the beta-channel context. Use a targeted `update <plugin-id>` when you
|
||||
intentionally want to keep an exact or tagged official spec untouched.
|
||||
|
||||
For npm installs, you can pass an explicit package spec to switch the tracked
|
||||
record:
|
||||
|
||||
|
||||
@@ -739,7 +739,7 @@ Write colocated tests in `src/channel.test.ts`:
|
||||
describeMessageTool and action discovery
|
||||
</Card>
|
||||
<Card title="Target resolution" icon="crosshair" href="/plugins/architecture-internals#channel-target-resolution">
|
||||
inferTargetChatType, looksLikeId, reservedLiterals, resolveTarget
|
||||
inferTargetChatType, looksLikeId, resolveTarget
|
||||
</Card>
|
||||
<Card title="Runtime helpers" icon="settings" href="/plugins/sdk-runtime">
|
||||
TTS, STT, media, subagent via api.runtime
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
* Azure Speech REST helpers. They normalize endpoints, build SSML, list voices,
|
||||
* and synthesize speech with response-size and SSRF guards.
|
||||
*/
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech-core";
|
||||
import { trimToUndefined } from "openclaw/plugin-sdk/speech-core";
|
||||
@@ -163,10 +160,7 @@ export async function listAzureSpeechVoices(params: {
|
||||
|
||||
try {
|
||||
await assertOkOrThrowProviderError(response, "Azure Speech voices API error");
|
||||
const voices = await readProviderJsonResponse<AzureSpeechVoiceEntry[]>(
|
||||
response,
|
||||
"azure-speech.voices",
|
||||
);
|
||||
const voices = (await response.json()) as AzureSpeechVoiceEntry[];
|
||||
return Array.isArray(voices)
|
||||
? voices
|
||||
.filter((voice) => !isDeprecatedVoice(voice))
|
||||
|
||||
@@ -1,70 +1,12 @@
|
||||
// Byteplus tests cover video generation provider plugin behavior.
|
||||
import {
|
||||
getProviderHttpMocks,
|
||||
installProviderHttpMockCleanup,
|
||||
} from "openclaw/plugin-sdk/provider-http-test-mocks";
|
||||
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Submit/poll transport is mocked locally so each test can inject the BytePlus task JSON
|
||||
// bodies, while readProviderJsonResponse is kept REAL (via importActual) so the byte-bounded
|
||||
// reader actually streams and cancels oversized bodies under test instead of a stub.
|
||||
const { postJsonRequestMock, fetchWithTimeoutMock, resolveApiKeyForProviderMock } = vi.hoisted(
|
||||
() => ({
|
||||
postJsonRequestMock: vi.fn(),
|
||||
fetchWithTimeoutMock: vi.fn(),
|
||||
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
|
||||
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", async (importActual) => {
|
||||
const actual = await importActual<typeof import("openclaw/plugin-sdk/provider-http")>();
|
||||
const resolveTimeoutMs = (timeoutMs: unknown): number =>
|
||||
typeof timeoutMs === "function" ? (timeoutMs() as number) : ((timeoutMs as number) ?? 60_000);
|
||||
return {
|
||||
// REAL byte-bounded JSON reader under test — not stubbed.
|
||||
readProviderJsonResponse: actual.readProviderJsonResponse,
|
||||
postJsonRequest: postJsonRequestMock,
|
||||
fetchProviderOperationResponse: async (params: {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
timeoutMs?: unknown;
|
||||
fetchFn: typeof fetch;
|
||||
}) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)),
|
||||
fetchProviderDownloadResponse: async (params: {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
timeoutMs?: unknown;
|
||||
fetchFn: typeof fetch;
|
||||
}) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)),
|
||||
assertOkOrThrowHttpError: async () => {},
|
||||
createProviderOperationDeadline: ({
|
||||
label,
|
||||
timeoutMs,
|
||||
}: {
|
||||
label: string;
|
||||
timeoutMs?: number;
|
||||
}) => ({ label, timeoutMs }),
|
||||
createProviderOperationTimeoutResolver:
|
||||
({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
|
||||
() =>
|
||||
defaultTimeoutMs,
|
||||
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
|
||||
defaultTimeoutMs,
|
||||
resolveProviderHttpRequestConfig: (params: {
|
||||
baseUrl?: string;
|
||||
defaultBaseUrl: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
}) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork === true,
|
||||
headers: new Headers(params.defaultHeaders),
|
||||
dispatcherPolicy: undefined,
|
||||
}),
|
||||
waitProviderOperationPollInterval: async () => {},
|
||||
};
|
||||
});
|
||||
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
|
||||
|
||||
let buildBytePlusVideoGenerationProvider: typeof import("./video-generation-provider.js").buildBytePlusVideoGenerationProvider;
|
||||
|
||||
@@ -72,22 +14,20 @@ beforeAll(async () => {
|
||||
({ buildBytePlusVideoGenerationProvider } = await import("./video-generation-provider.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
postJsonRequestMock.mockReset();
|
||||
fetchWithTimeoutMock.mockReset();
|
||||
resolveApiKeyForProviderMock.mockClear();
|
||||
});
|
||||
installProviderHttpMockCleanup();
|
||||
|
||||
function mockSuccessfulBytePlusTask(params?: { model?: string }) {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
id: "task_123",
|
||||
}),
|
||||
response: {
|
||||
json: async () => ({
|
||||
id: "task_123",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
id: "task_123",
|
||||
status: "succeeded",
|
||||
content: {
|
||||
@@ -95,7 +35,7 @@ function mockSuccessfulBytePlusTask(params?: { model?: string }) {
|
||||
},
|
||||
model: params?.model ?? "seedance-1-0-lite-t2v-250428",
|
||||
}),
|
||||
)
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/webm" }),
|
||||
arrayBuffer: async () => Buffer.from("webm-bytes"),
|
||||
@@ -137,53 +77,6 @@ function streamedVideoResponse(bytes: string): Response {
|
||||
);
|
||||
}
|
||||
|
||||
// BytePlus submit/poll task JSON is now read through the byte-bounded reader, so the
|
||||
// mocked responses must expose a real readable body (not just a json() shortcut).
|
||||
function streamedJsonResponse(payload: unknown): Response {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(JSON.stringify(payload)));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Builds a JSON body larger than the shared 16 MiB readProviderJsonResponse cap so the
|
||||
// bounded reader cancels the stream mid-flight; if the cap were removed the reader would
|
||||
// buffer the whole advertised payload before parsing. Tracks how many bytes were pulled
|
||||
// and whether the stream was canceled so callers can assert the body was not fully read.
|
||||
function makeOversizedJsonStream(): {
|
||||
body: ReadableStream<Uint8Array>;
|
||||
maxBytes: number;
|
||||
totalBytes: number;
|
||||
state: { bytesPulled: number; canceled: boolean };
|
||||
} {
|
||||
const maxBytes = 16 * 1024 * 1024; // matches PROVIDER_JSON_RESPONSE_MAX_BYTES.
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
const state = { bytesPulled: 0, canceled: false };
|
||||
let pulled = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (pulled >= TOTAL_CHUNKS) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
pulled += 1;
|
||||
state.bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
state.canceled = true;
|
||||
},
|
||||
});
|
||||
return { body, maxBytes, totalBytes: TOTAL_CHUNKS * ONE_MIB, state };
|
||||
}
|
||||
|
||||
describe("byteplus video generation provider", () => {
|
||||
it("declares explicit mode capabilities", () => {
|
||||
expectExplicitVideoGenerationCapabilities(buildBytePlusVideoGenerationProvider());
|
||||
@@ -217,19 +110,21 @@ describe("byteplus video generation provider", () => {
|
||||
|
||||
it("rejects generated video downloads that exceed the configured media cap", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task_too_large" }),
|
||||
response: {
|
||||
json: async () => ({ id: "task_too_large" }),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
id: "task_too_large",
|
||||
status: "succeeded",
|
||||
content: {
|
||||
video_url: "https://example.com/too-large.mp4",
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
@@ -327,14 +222,16 @@ describe("byteplus video generation provider", () => {
|
||||
|
||||
it("drops malformed response duration metadata", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({
|
||||
id: "task_123",
|
||||
}),
|
||||
response: {
|
||||
json: async () => ({
|
||||
id: "task_123",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
id: "task_123",
|
||||
status: "succeeded",
|
||||
content: {
|
||||
@@ -342,7 +239,7 @@ describe("byteplus video generation provider", () => {
|
||||
},
|
||||
duration: 1.5,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
arrayBuffer: async () => Buffer.from("mp4-bytes"),
|
||||
@@ -362,15 +259,11 @@ describe("byteplus video generation provider", () => {
|
||||
it("reports malformed create JSON with a provider-owned error", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("{ not valid json"));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
response: {
|
||||
json: async () => {
|
||||
throw new SyntaxError("bad json");
|
||||
},
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
@@ -388,17 +281,19 @@ describe("byteplus video generation provider", () => {
|
||||
|
||||
it("rejects status responses missing a task status", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task_missing_status" }),
|
||||
response: {
|
||||
json: async () => ({ id: "task_missing_status" }),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
id: "task_missing_status",
|
||||
content: {
|
||||
video_url: "https://example.com/byteplus.mp4",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await expect(
|
||||
@@ -413,16 +308,18 @@ describe("byteplus video generation provider", () => {
|
||||
|
||||
it("rejects malformed completed content", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task_malformed_content" }),
|
||||
response: {
|
||||
json: async () => ({ id: "task_malformed_content" }),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
streamedJsonResponse({
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
id: "task_malformed_content",
|
||||
status: "succeeded",
|
||||
content: ["https://example.com/byteplus.mp4"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await expect(
|
||||
@@ -434,61 +331,4 @@ describe("byteplus video generation provider", () => {
|
||||
}),
|
||||
).rejects.toThrow("BytePlus video generation completed with malformed content");
|
||||
});
|
||||
|
||||
it("bounds the submit task JSON body and cancels an oversized stream", async () => {
|
||||
const stream = makeOversizedJsonStream();
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(stream.body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release,
|
||||
});
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-lite-t2v-250428",
|
||||
prompt: "oversized submit response",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`BytePlus video generation failed: JSON response exceeds ${stream.maxBytes} bytes`,
|
||||
);
|
||||
expect(stream.state.canceled).toBe(true);
|
||||
// Only the bounded prefix is pulled, never the full advertised stream.
|
||||
expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes);
|
||||
// The submit request must still be released even though the body overflowed.
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("bounds the poll status JSON body and cancels an oversized stream", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: streamedJsonResponse({ id: "task_oversized_poll" }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
const stream = makeOversizedJsonStream();
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
new Response(stream.body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-lite-t2v-250428",
|
||||
prompt: "oversized poll response",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`BytePlus video status request failed: JSON response exceeds ${stream.maxBytes} bytes`,
|
||||
);
|
||||
expect(stream.state.canceled).toBe(true);
|
||||
expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
fetchProviderDownloadResponse,
|
||||
fetchProviderOperationResponse,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
resolveProviderHttpRequestConfig,
|
||||
waitProviderOperationPollInterval,
|
||||
@@ -56,13 +55,16 @@ type BytePlusTaskResponse = {
|
||||
|
||||
type BytePlusTaskStatus = "running" | "failed" | "queued" | "succeeded" | "cancelled";
|
||||
|
||||
async function readBytePlusJsonResponse<T>(response: Response, label: string): Promise<T> {
|
||||
// BytePlus submit/poll task bodies are read through the shared byte-bounded reader
|
||||
// (readResponseWithLimit, via readProviderJsonResponse) so a hostile or buggy endpoint
|
||||
// that streams an unbounded JSON body cannot force the runtime to buffer the whole
|
||||
// payload before parsing. Overflow cancels the stream and throws a bounded error;
|
||||
// malformed JSON keeps the existing `${label}: malformed JSON response` wrapping.
|
||||
const payload = await readProviderJsonResponse<unknown>(response, label);
|
||||
async function readBytePlusJsonResponse<T>(
|
||||
response: Pick<Response, "json">,
|
||||
label: string,
|
||||
): Promise<T> {
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (cause) {
|
||||
throw new Error(`${label}: malformed JSON response`, { cause });
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error(`${label}: malformed JSON response`);
|
||||
}
|
||||
|
||||
@@ -639,15 +639,6 @@ function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse)
|
||||
return detectedVersion;
|
||||
}
|
||||
|
||||
export function isUnsupportedCodexAppServerVersionError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.message.startsWith(
|
||||
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCodexAppServerRuntimeIdentity(
|
||||
response: CodexInitializeResponse,
|
||||
serverVersion: string,
|
||||
|
||||
@@ -167,7 +167,6 @@ export type CodexAppServerStartOptions = {
|
||||
transport: CodexAppServerTransportMode;
|
||||
command: string;
|
||||
commandSource?: CodexAppServerCommandSource;
|
||||
managedFallbackCommandPaths?: string[];
|
||||
args: string[];
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
@@ -333,9 +332,7 @@ const codexAppServerNetworkProxySchema = z
|
||||
baseProfile: z.enum(["read-only", "workspace"]).optional(),
|
||||
mode: z.enum(["limited", "full"]).optional(),
|
||||
domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(),
|
||||
unixSockets: z
|
||||
.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema)
|
||||
.optional(),
|
||||
unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(),
|
||||
proxyUrl: z.string().trim().min(1).optional(),
|
||||
socksUrl: z.string().trim().min(1).optional(),
|
||||
enableSocks5: z.boolean().optional(),
|
||||
@@ -877,7 +874,6 @@ export function codexAppServerStartOptionsKey(
|
||||
transport: options.transport,
|
||||
command: options.command,
|
||||
commandSource: options.commandSource ?? null,
|
||||
managedFallbackCommandPaths: [...(options.managedFallbackCommandPaths ?? [])],
|
||||
args: options.args,
|
||||
url: options.url ?? null,
|
||||
authToken: hashSecretForKey(options.authToken, "authToken"),
|
||||
|
||||
@@ -1102,6 +1102,585 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks delivered message-tool-only source replies as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "imessage-6264" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "receipt-redactor",
|
||||
pluginName: "Receipt redactor",
|
||||
rawHandler: () => undefined,
|
||||
handler: (event: { result: AgentToolResult<unknown> }) => ({
|
||||
result: {
|
||||
content: event.result.content,
|
||||
details: { redacted: true },
|
||||
},
|
||||
}),
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "imessage-6264",
|
||||
platformMessageIds: ["imessage-6264"],
|
||||
},
|
||||
}),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "chat-1",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "chat-1",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps normalized explicit source routes terminal", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "sms",
|
||||
plugin: {
|
||||
id: "sms",
|
||||
messaging: {
|
||||
normalizeTarget: (raw: string) => {
|
||||
const digits = raw.replace(/\D/gu, "");
|
||||
return digits.length === 11 && digits.startsWith("1") ? `+${digits}` : raw.trim();
|
||||
},
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { ok: true, messageId: "sms-853" }),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "sms",
|
||||
currentChannelId: "sms:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "sms",
|
||||
target: "+1 (206) 910-6512",
|
||||
messageId: "853",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "sms",
|
||||
to: "+12069106512",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-857",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-857",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "857",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "+12069106512",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
|
||||
const receiptText = JSON.stringify({
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-861",
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-861",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "861",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText(receiptText));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not let dry-run reply receipts terminate message-tool-only source replies", async () => {
|
||||
const receiptText = JSON.stringify({
|
||||
deliveryStatus: "dry_run",
|
||||
dryRun: true,
|
||||
replyToId: "provider-guid-862",
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-862",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "862",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText(receiptText));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not record dry-run reply actions as committed sends", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Dry run.", {
|
||||
deliveryStatus: "dry_run",
|
||||
dryRun: true,
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
currentMessageId: "provider-guid-862",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "862",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Dry run."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(false);
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([]);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "863",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "865",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("records message-tool-owned terminal replies as delivered source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
{
|
||||
...textToolResult("Sent.", { ok: true }),
|
||||
terminate: true,
|
||||
} as AgentToolResult<unknown>,
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "867",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
|
||||
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createTool({ name: "message", execute })],
|
||||
signal: new AbortController().signal,
|
||||
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
|
||||
});
|
||||
|
||||
const firstResult = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
const secondResult = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-2",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "inspect" },
|
||||
});
|
||||
|
||||
expect(firstResult.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(secondResult).toEqual(expectInputText("No message sent."));
|
||||
expect(secondResult.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mark explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "other-chat-message" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "channel:other",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "cross-provider reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark same-target sibling-thread replies as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
currentThreadId: "171.222",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "C123",
|
||||
threadId: "171.333",
|
||||
message: "sibling thread reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark implicit-target sibling-thread replies as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
currentThreadId: "171.222",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
threadId: "171.333",
|
||||
message: "sibling thread reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark top-level source replies with explicit thread routes as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "C123",
|
||||
threadId: "171.333",
|
||||
message: "thread reply from top-level source",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let matching reply receipts override explicit non-source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "other-chat-message",
|
||||
repliedTo: "provider-guid-853",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
currentMessageId: "provider-guid-853",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "other-chat",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let provider target aliases override source routes", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: {
|
||||
id: "slack",
|
||||
messaging: { normalizeTarget: (raw: string) => raw.trim().toLowerCase() },
|
||||
actions: {
|
||||
messageActionTargetAliases: {
|
||||
reply: {
|
||||
aliases: ["chatGuid"],
|
||||
deliveryTargetAliases: ["chatGuid"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "channel:c1",
|
||||
currentMessagingTarget: "channel:c1",
|
||||
currentMessageId: "provider-guid-854",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
chatGuid: "Channel:C2",
|
||||
messageId: "854",
|
||||
message: "cross-chat reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "slack",
|
||||
to: "channel:c2",
|
||||
text: "cross-chat reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not record messaging side effects when the send fails", async () => {
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
getChannelAgentToolMeta,
|
||||
getPluginToolMeta,
|
||||
type EmbeddedRunAttemptParams,
|
||||
isDeliveredMessageToolOnlySourceReplyResult,
|
||||
isDeliveredMessagingToolResult,
|
||||
isReplaySafeToolCall,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isToolResultError,
|
||||
@@ -63,9 +65,11 @@ type CodexDynamicToolHookContext = {
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentMessageId?: string | number;
|
||||
currentThreadId?: string;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
|
||||
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
|
||||
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
|
||||
};
|
||||
@@ -100,6 +104,225 @@ function applyCurrentMessageProvider(
|
||||
return { ...args, provider };
|
||||
}
|
||||
|
||||
function normalizeRouteToken(value: string | number | undefined): string | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
|
||||
const tokens = new Set<string>();
|
||||
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
if (currentTarget) {
|
||||
tokens.add(currentTarget);
|
||||
}
|
||||
if (currentChannel) {
|
||||
tokens.add(currentChannel);
|
||||
}
|
||||
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
|
||||
if (channelPrefixIndex >= 0 && currentChannel) {
|
||||
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
for (const segment of unprefixedChannel.split(/[;,]/u)) {
|
||||
const token = normalizeRouteToken(segment);
|
||||
if (token) {
|
||||
tokens.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
|
||||
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function routeTokenMatchesSource(
|
||||
token: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
|
||||
}
|
||||
|
||||
function routeProviderMatchesSource(
|
||||
provider: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(provider);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
|
||||
}
|
||||
|
||||
function routeTokenMatchesCurrentMessage(
|
||||
token: string | number | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return (
|
||||
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
|
||||
);
|
||||
}
|
||||
|
||||
function readRouteToken(record: Record<string, unknown>, key: string): string | number | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function explicitRouteTokensMismatchCurrent(
|
||||
args: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
currentToken: string | number | undefined,
|
||||
): boolean {
|
||||
const normalizedCurrent = normalizeRouteToken(currentToken);
|
||||
if (!normalizedCurrent) {
|
||||
return false;
|
||||
}
|
||||
return keys.some((key) => {
|
||||
const normalized = normalizeRouteToken(readRouteToken(args, key));
|
||||
return normalized !== undefined && normalized !== normalizedCurrent;
|
||||
});
|
||||
}
|
||||
|
||||
function explicitThreadRouteTargetsNonSource(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const normalizedCurrentThread = normalizeRouteToken(hookContext?.currentThreadId);
|
||||
const explicitThreadTokens = [
|
||||
...EXPLICIT_MESSAGE_THREAD_KEYS.map((key) => normalizeRouteToken(readRouteToken(args, key))),
|
||||
normalizeRouteToken(messagingTarget?.threadId),
|
||||
].filter((value): value is string => value !== undefined);
|
||||
|
||||
if (explicitThreadTokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedCurrentThread === undefined ||
|
||||
explicitThreadTokens.some((value) => value !== normalizedCurrentThread)
|
||||
);
|
||||
}
|
||||
|
||||
function replyReceiptMatchesCurrentMessage(
|
||||
value: unknown,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
depth = 0,
|
||||
): boolean {
|
||||
if (depth > 4 || value === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
|
||||
if (
|
||||
routeTokenMatchesCurrentMessage(
|
||||
typeof record[key] === "string" ? record[key] : undefined,
|
||||
hookContext,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
"content",
|
||||
"details",
|
||||
"payload",
|
||||
"receipt",
|
||||
"result",
|
||||
"results",
|
||||
"sendResult",
|
||||
"text",
|
||||
]) {
|
||||
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasExplicitNonSourceMessageRoute(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
|
||||
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
|
||||
if (
|
||||
provider &&
|
||||
currentProvider !== provider &&
|
||||
!routeProviderMatchesSource(provider, hookContext)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const targetValues = [
|
||||
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
|
||||
typeof args[key] === "string" ? args[key] : undefined,
|
||||
),
|
||||
...(Array.isArray(args.targets)
|
||||
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
|
||||
: []),
|
||||
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
|
||||
if (explicitThreadRouteTargetsNonSource(args, hookContext, messagingTarget)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
explicitRouteTokensMismatchCurrent(
|
||||
args,
|
||||
EXPLICIT_MESSAGE_REPLY_KEYS,
|
||||
hookContext?.currentMessageId,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
messagingTarget?.to !== undefined &&
|
||||
!routeTokenMatchesSource(messagingTarget.to, hookContext)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (messagingTarget?.to !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (targetValues.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Runtime bridge returned to Codex app-server attempt code. */
|
||||
export type CodexDynamicToolBridge = {
|
||||
availableSpecs: CodexDynamicToolSpec[];
|
||||
@@ -114,6 +337,7 @@ export type CodexDynamicToolBridge = {
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -132,6 +356,10 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
|
||||
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
|
||||
// spawn_agent remains the primary Codex subagent surface.
|
||||
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
|
||||
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
|
||||
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
|
||||
const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"];
|
||||
const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"];
|
||||
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
|
||||
|
||||
/**
|
||||
@@ -176,6 +404,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
|
||||
const telemetry: CodexDynamicToolBridge["telemetry"] = {
|
||||
didSendViaMessagingTool: false,
|
||||
didDeliverSourceReplyViaMessageTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
@@ -333,10 +562,9 @@ export function createCodexDynamicToolBridge(params: {
|
||||
executedArgs,
|
||||
params.hookContext?.currentChannelProvider,
|
||||
);
|
||||
const messagingTarget =
|
||||
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const messagingTarget = isMessagingTool(toolName)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const confirmedMessagingTarget =
|
||||
!rawIsError && messagingTarget
|
||||
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
|
||||
@@ -358,12 +586,53 @@ export function createCodexDynamicToolBridge(params: {
|
||||
},
|
||||
terminalType,
|
||||
);
|
||||
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
|
||||
executedArgs,
|
||||
params.hookContext,
|
||||
confirmedMessagingTarget,
|
||||
);
|
||||
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
|
||||
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
|
||||
toolName,
|
||||
args: executedArgs,
|
||||
result,
|
||||
hookResult: rawResult,
|
||||
isError: resultIsError,
|
||||
allowExplicitSourceRoute: !blocksSourceReplyTermination,
|
||||
});
|
||||
const receiptConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
normalizeRouteToken(
|
||||
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
|
||||
) === "reply" &&
|
||||
!resultIsError &&
|
||||
!blocksSourceReplyTermination &&
|
||||
isDeliveredMessagingToolResult({
|
||||
toolName,
|
||||
args: executedArgs,
|
||||
result,
|
||||
hookResult: rawResult,
|
||||
isError: resultIsError,
|
||||
}) &&
|
||||
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
|
||||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
|
||||
const toolConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
!resultIsError &&
|
||||
(rawResult.terminate === true || result.terminate === true);
|
||||
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
|
||||
telemetry.didDeliverSourceReplyViaMessageTool = true;
|
||||
}
|
||||
withDynamicToolTermination(
|
||||
response,
|
||||
rawResult.terminate === true ||
|
||||
result.terminate === true ||
|
||||
isToolResultYield(rawResult) ||
|
||||
isToolResultYield(result),
|
||||
isToolResultYield(result) ||
|
||||
deliveredSourceReply ||
|
||||
receiptConfirmedSourceReply,
|
||||
);
|
||||
const asyncStarted =
|
||||
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
|
||||
@@ -801,9 +1070,22 @@ function collectToolTelemetry(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isMessagingTool(params.toolName)) {
|
||||
return;
|
||||
}
|
||||
const isMessagingSendAction = isMessagingToolSendAction(params.toolName, params.args);
|
||||
if (!isMessagingSendAction && !params.messagingTarget) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isMessagingTool(params.toolName) ||
|
||||
!isMessagingToolSendAction(params.toolName, params.args)
|
||||
!isMessagingSendAction &&
|
||||
!isDeliveredMessagingToolResult({
|
||||
toolName: params.toolName,
|
||||
args: params.args,
|
||||
result: params.result,
|
||||
hookResult: params.mediaTrustResult,
|
||||
isError: params.isError,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -836,6 +836,19 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.toolMediaUrls).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("propagates message-tool-only source reply delivery telemetry", async () => {
|
||||
const projector = await createProjector();
|
||||
|
||||
const result = projector.buildResult({
|
||||
...buildEmptyToolTelemetry(),
|
||||
didSendViaMessagingTool: true,
|
||||
didDeliverSourceReplyViaMessageTool: true,
|
||||
});
|
||||
|
||||
expect(result.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote repeated tool progress text to the final assistant reply", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const projector = await createProjector({
|
||||
|
||||
@@ -53,6 +53,7 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
|
||||
|
||||
export type CodexAppServerToolTelemetry = {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -411,6 +412,8 @@ export class CodexAppServerEventProjector {
|
||||
currentAttemptAssistant,
|
||||
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
|
||||
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
|
||||
didDeliverSourceReplyViaMessageTool:
|
||||
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
|
||||
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
||||
|
||||
@@ -27,8 +27,6 @@ function managedCommandPath(root: string, platform: NodeJS.Platform): string {
|
||||
return pathApi.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex");
|
||||
}
|
||||
|
||||
const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex";
|
||||
|
||||
describe("managed Codex app-server binary", () => {
|
||||
it("leaves explicit command overrides unchanged", async () => {
|
||||
const explicitOptions = startOptions("config");
|
||||
@@ -43,14 +41,10 @@ describe("managed Codex app-server binary", () => {
|
||||
expect(pathExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers the macOS desktop app bundle when it exists", async () => {
|
||||
it("resolves the plugin-local bundled Codex binary", async () => {
|
||||
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
|
||||
const paths = resolveManagedCodexAppServerPaths({ platform: "darwin", pluginRoot });
|
||||
const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin");
|
||||
const pathExists = vi.fn(
|
||||
async (filePath: string) =>
|
||||
filePath === MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND || filePath === pluginLocalCommand,
|
||||
);
|
||||
const pathExists = vi.fn(async (filePath: string) => filePath === paths.commandPath);
|
||||
|
||||
await expect(
|
||||
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
|
||||
@@ -60,31 +54,10 @@ describe("managed Codex app-server binary", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions("managed"),
|
||||
command: MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND,
|
||||
commandSource: "resolved-managed",
|
||||
managedFallbackCommandPaths: [pluginLocalCommand],
|
||||
});
|
||||
expect(paths.commandPath).toBe(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND);
|
||||
expect(paths.candidateCommandPaths).toContain(pluginLocalCommand);
|
||||
});
|
||||
|
||||
it("falls back to the plugin-local bundled Codex binary on macOS", async () => {
|
||||
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
|
||||
const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin");
|
||||
const pathExists = vi.fn(async (filePath: string) => filePath === pluginLocalCommand);
|
||||
|
||||
await expect(
|
||||
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
|
||||
platform: "darwin",
|
||||
pluginRoot,
|
||||
pathExists,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions("managed"),
|
||||
command: pluginLocalCommand,
|
||||
command: paths.commandPath,
|
||||
commandSource: "resolved-managed",
|
||||
});
|
||||
expect(pathExists).toHaveBeenCalledWith(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, "darwin");
|
||||
expect(paths.commandPath).toBe(managedCommandPath(pluginRoot, "darwin"));
|
||||
});
|
||||
|
||||
it("resolves Windows Codex command shims", () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE } from "./version.js";
|
||||
|
||||
const CODEX_APP_SERVER_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CODEX_PLUGIN_ROOT = resolveDefaultCodexPluginRoot(CODEX_APP_SERVER_MODULE_DIR);
|
||||
const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex";
|
||||
|
||||
type ManagedCodexAppServerPaths = {
|
||||
commandPath: string;
|
||||
@@ -40,19 +39,16 @@ export async function resolveManagedCodexAppServerStartOptions(
|
||||
pluginRoot: options.pluginRoot,
|
||||
});
|
||||
const pathExists = options.pathExists ?? commandPathExists;
|
||||
const commandPaths = await findManagedCodexAppServerCommandPaths({
|
||||
const commandPath = await findManagedCodexAppServerCommandPath({
|
||||
candidateCommandPaths: paths.candidateCommandPaths,
|
||||
pathExists,
|
||||
platform,
|
||||
});
|
||||
const commandPath = commandPaths[0];
|
||||
const managedFallbackCommandPaths = commandPaths.slice(1);
|
||||
|
||||
return {
|
||||
...startOptions,
|
||||
command: commandPath,
|
||||
commandSource: "resolved-managed",
|
||||
...(managedFallbackCommandPaths.length > 0 ? { managedFallbackCommandPaths } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,17 +77,12 @@ function resolveManagedCodexAppServerCommandCandidates(
|
||||
const roots = resolveManagedCodexAppServerCandidateRoots(pluginRoot, platform);
|
||||
return [
|
||||
...new Set([
|
||||
...resolveDesktopCodexAppServerCommandCandidates(platform),
|
||||
...roots.map((root) => pathApi.join(root, "node_modules", ".bin", commandName)),
|
||||
...resolveManagedCodexPackageBinCandidates(roots, platform),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveDesktopCodexAppServerCommandCandidates(platform: NodeJS.Platform): string[] {
|
||||
return platform === "darwin" ? [MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND] : [];
|
||||
}
|
||||
|
||||
function resolveDefaultCodexPluginRoot(moduleDir: string): string {
|
||||
const moduleBaseName = path.basename(moduleDir);
|
||||
if (moduleBaseName === "dist" || moduleBaseName === "dist-runtime") {
|
||||
@@ -204,20 +195,16 @@ function pathForPlatform(platform: NodeJS.Platform): typeof path {
|
||||
return platform === "win32" ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
async function findManagedCodexAppServerCommandPaths(params: {
|
||||
async function findManagedCodexAppServerCommandPath(params: {
|
||||
candidateCommandPaths: readonly string[];
|
||||
pathExists: (filePath: string, platform: NodeJS.Platform) => Promise<boolean>;
|
||||
platform: NodeJS.Platform;
|
||||
}): Promise<string[]> {
|
||||
const commandPaths: string[] = [];
|
||||
}): Promise<string> {
|
||||
for (const commandPath of params.candidateCommandPaths) {
|
||||
if (await params.pathExists(commandPath, params.platform)) {
|
||||
commandPaths.push(commandPath);
|
||||
return commandPath;
|
||||
}
|
||||
}
|
||||
if (commandPaths.length > 0) {
|
||||
return commandPaths;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
|
||||
@@ -841,9 +841,11 @@ export async function runCodexAppServerAttempt(
|
||||
currentChannelProvider: resolveCodexMessageToolProvider(params),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
currentMessageId: params.currentMessageId,
|
||||
currentThreadId: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
onToolOutcome: onCodexToolOutcome,
|
||||
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
|
||||
},
|
||||
|
||||
@@ -187,41 +187,6 @@ describe("shared Codex app-server client", () => {
|
||||
startSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to the next managed app-server when desktop initialize is unsupported", async () => {
|
||||
const desktop = createClientHarness();
|
||||
const pluginLocal = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(desktop.client)
|
||||
.mockReturnValueOnce(pluginLocal.client);
|
||||
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => ({
|
||||
...startOptions,
|
||||
command: "/Applications/Codex.app/Contents/Resources/codex",
|
||||
commandSource: "resolved-managed",
|
||||
managedFallbackCommandPaths: ["/cache/openclaw/codex"],
|
||||
}));
|
||||
|
||||
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)");
|
||||
await sendInitializeResult(pluginLocal, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(pluginLocal);
|
||||
|
||||
await expect(listPromise).resolves.toEqual({ models: [] });
|
||||
expect(desktop.process.stdin.destroyed).toBe(true);
|
||||
expect(pluginLocal.process.stdin.destroyed).toBe(false);
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(startSpy.mock.calls[0]?.[0]).toMatchObject({
|
||||
command: "/Applications/Codex.app/Contents/Resources/codex",
|
||||
commandSource: "resolved-managed",
|
||||
managedFallbackCommandPaths: ["/cache/openclaw/codex"],
|
||||
});
|
||||
expect(startSpy.mock.calls[1]?.[0]).toMatchObject({
|
||||
command: "/cache/openclaw/codex",
|
||||
commandSource: "resolved-managed",
|
||||
});
|
||||
expect(startSpy.mock.calls[1]?.[0]).not.toHaveProperty("managedFallbackCommandPaths");
|
||||
});
|
||||
|
||||
it("closes and clears a shared app-server when initialize times out", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveCodexAppServerAuthProfileStore,
|
||||
resolveCodexAppServerFallbackApiKeyCacheKey,
|
||||
} from "./auth-bridge.js";
|
||||
import { CodexAppServerClient, isUnsupportedCodexAppServerVersionError } from "./client.js";
|
||||
import { CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
codexAppServerStartOptionsKey,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
@@ -242,23 +242,27 @@ async function acquireSharedCodexAppServerClient(
|
||||
const sharedPromise =
|
||||
entry.promise ??
|
||||
(entry.promise = (async () => {
|
||||
const client = await startInitializedCodexAppServerClient({
|
||||
startOptions,
|
||||
agentDir,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
config: options?.config,
|
||||
onStartedClient: (startedClient) => {
|
||||
entry.client = startedClient;
|
||||
startedClient.setActiveSharedLeaseCountProviderForUnscopedNotifications(
|
||||
() => entry.activeLeases,
|
||||
);
|
||||
options?.onStartedClient?.(startedClient);
|
||||
},
|
||||
});
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
entry.client = client;
|
||||
options?.onStartedClient?.(client);
|
||||
client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases);
|
||||
client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient));
|
||||
return client;
|
||||
try {
|
||||
await client.initialize();
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client,
|
||||
agentDir,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
startOptions,
|
||||
config: options?.config,
|
||||
});
|
||||
return client;
|
||||
} catch (error) {
|
||||
// Startup failures happen before callers own the shared client, so close
|
||||
// the child here instead of leaving a rejected daemon attached to stdio.
|
||||
client.close();
|
||||
throw error;
|
||||
}
|
||||
})());
|
||||
try {
|
||||
const client = await withTimeout(
|
||||
@@ -287,110 +291,39 @@ export async function createIsolatedCodexAppServerClient(
|
||||
): Promise<CodexAppServerClient> {
|
||||
const { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions } =
|
||||
await resolveCodexAppServerClientStartContext(options);
|
||||
return await startInitializedCodexAppServerClient({
|
||||
startOptions,
|
||||
agentDir,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
authProfileStore,
|
||||
config: options?.config,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
});
|
||||
}
|
||||
|
||||
async function startInitializedCodexAppServerClient(params: {
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
agentDir: string;
|
||||
authProfileId: string | null | undefined;
|
||||
authProfileStore?: AuthProfileStore;
|
||||
config?: CodexAppServerClientOptions["config"];
|
||||
timeoutMs?: number;
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
}): Promise<CodexAppServerClient> {
|
||||
const startOptionsCandidates = resolveManagedFallbackStartOptions(params.startOptions);
|
||||
for (let index = 0; index < startOptionsCandidates.length; index += 1) {
|
||||
const startOptions = startOptionsCandidates[index];
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
params.onStartedClient?.(client);
|
||||
const initialize = client.initialize();
|
||||
try {
|
||||
await withTimeout(initialize, params.timeoutMs ?? 0, "codex app-server initialize timed out");
|
||||
} catch (error) {
|
||||
client.close();
|
||||
void initialize.catch(() => undefined);
|
||||
if (shouldTryManagedFallbackStartOption(error, startOptions, index, startOptionsCandidates)) {
|
||||
continue;
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
if (authProfileId) {
|
||||
// Profile-backed Codex auth is ephemeral. Keep the host refresh callback
|
||||
// available whether the profile came from a scoped store or persisted state.
|
||||
client.addRequestHandler(async (request) => {
|
||||
if (request.method !== "account/chatgptAuthTokens/refresh") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (params.authProfileId) {
|
||||
// Profile-backed Codex auth is ephemeral. Keep the host refresh callback
|
||||
// available whether the profile came from a scoped store or persisted state.
|
||||
client.addRequestHandler(async (request) => {
|
||||
if (request.method !== "account/chatgptAuthTokens/refresh") {
|
||||
return undefined;
|
||||
}
|
||||
return await refreshCodexAppServerAuthTokens({
|
||||
agentDir: params.agentDir,
|
||||
authProfileId: params.authProfileId!,
|
||||
...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}),
|
||||
config: params.config,
|
||||
});
|
||||
return await refreshCodexAppServerAuthTokens({
|
||||
agentDir,
|
||||
authProfileId,
|
||||
...(authProfileStore ? { authProfileStore } : {}),
|
||||
config: options?.config,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client,
|
||||
agentDir: params.agentDir,
|
||||
authProfileId: params.authProfileId,
|
||||
startOptions,
|
||||
config: params.config,
|
||||
...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}),
|
||||
});
|
||||
return client;
|
||||
} catch (error) {
|
||||
client.close();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
throw new Error("Managed Codex app-server fallback candidates were exhausted.");
|
||||
}
|
||||
|
||||
function resolveManagedFallbackStartOptions(
|
||||
startOptions: CodexAppServerStartOptions,
|
||||
): CodexAppServerStartOptions[] {
|
||||
const commands = [startOptions.command, ...(startOptions.managedFallbackCommandPaths ?? [])];
|
||||
const candidates: CodexAppServerStartOptions[] = [];
|
||||
for (let index = 0; index < commands.length; index += 1) {
|
||||
const command = commands[index];
|
||||
const managedFallbackCommandPaths = commands.slice(index + 1);
|
||||
const candidate = {
|
||||
...startOptions,
|
||||
command,
|
||||
};
|
||||
if (managedFallbackCommandPaths.length === 0) {
|
||||
delete candidate.managedFallbackCommandPaths;
|
||||
} else {
|
||||
candidate.managedFallbackCommandPaths = managedFallbackCommandPaths;
|
||||
}
|
||||
candidates.push(candidate);
|
||||
const initialize = client.initialize();
|
||||
try {
|
||||
await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out");
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client,
|
||||
agentDir,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
startOptions,
|
||||
config: options?.config,
|
||||
...(authProfileStore ? { authProfileStore } : {}),
|
||||
});
|
||||
return client;
|
||||
} catch (error) {
|
||||
client.close();
|
||||
void initialize.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function shouldTryManagedFallbackStartOption(
|
||||
error: unknown,
|
||||
startOptions: CodexAppServerStartOptions,
|
||||
index: number,
|
||||
startOptionsCandidates: readonly CodexAppServerStartOptions[],
|
||||
): boolean {
|
||||
return (
|
||||
startOptions.commandSource === "resolved-managed" &&
|
||||
index < startOptionsCandidates.length - 1 &&
|
||||
isUnsupportedCodexAppServerVersionError(error)
|
||||
);
|
||||
}
|
||||
|
||||
/** Clears and closes all shared clients for deterministic tests. */
|
||||
|
||||
@@ -172,24 +172,6 @@ describe("hydrateViewer", () => {
|
||||
expect(document.documentElement.dataset.openclawDiffsError).toBeUndefined();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("replaces stale controllers when hydrating the current cards again", async () => {
|
||||
renderCard();
|
||||
const { controllers, hydrateViewer } = await import("./viewer-client.js");
|
||||
controllers.splice(0);
|
||||
|
||||
await hydrateViewer();
|
||||
expect(controllers).toHaveLength(1);
|
||||
const firstController = controllers[0];
|
||||
|
||||
document.body.innerHTML = "";
|
||||
renderCard();
|
||||
await hydrateViewer();
|
||||
|
||||
expect(controllers).toHaveLength(1);
|
||||
expect(controllers[0]).not.toBe(firstController);
|
||||
expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewerState initialization", () => {
|
||||
|
||||
@@ -287,9 +287,6 @@ function syncAllControllers(): void {
|
||||
}
|
||||
|
||||
export async function hydrateViewer(): Promise<void> {
|
||||
// Rehydration replaces the current DOM card set; do not retain controllers
|
||||
// from a previous render because they can keep stale DOM references alive.
|
||||
controllers.length = 0;
|
||||
const cards = await Promise.all(
|
||||
getCards().map(async ({ host, payload }) => ({
|
||||
host,
|
||||
|
||||
@@ -345,7 +345,7 @@ describe("discordOutbound", () => {
|
||||
2,
|
||||
);
|
||||
expect(messageOptions.accountId).toBe("default");
|
||||
expect(messageOptions.replyTo).toBe("reply-1");
|
||||
expect(messageOptions.replyTo).toBeUndefined();
|
||||
|
||||
const mediaCall = mockCall(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1);
|
||||
expect(mediaCall[0]).toBe("channel:123456");
|
||||
@@ -353,7 +353,7 @@ describe("discordOutbound", () => {
|
||||
const mediaOptions = mockObjectArg(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1, 2);
|
||||
expect(mediaOptions.accountId).toBe("default");
|
||||
expect(mediaOptions.mediaUrl).toBe("https://example.com/extra.png");
|
||||
expect(mediaOptions.replyTo).toBe("reply-1");
|
||||
expect(mediaOptions.replyTo).toBeUndefined();
|
||||
expect(result).toEqual({
|
||||
channel: "discord",
|
||||
messageId: "msg-1",
|
||||
@@ -361,31 +361,6 @@ describe("discordOutbound", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps captured replyTo on audioAsVoice sends when replyToMode is batched", async () => {
|
||||
await discordOutbound.sendPayload?.({
|
||||
cfg: {},
|
||||
to: "channel:123456",
|
||||
text: "",
|
||||
payload: {
|
||||
text: "voice note",
|
||||
mediaUrls: ["https://example.com/voice.ogg", "https://example.com/extra.png"],
|
||||
audioAsVoice: true,
|
||||
},
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
replyToMode: "batched",
|
||||
});
|
||||
|
||||
expect(
|
||||
mockObjectArg(hoisted.sendVoiceMessageDiscordMock, "sendVoiceMessageDiscord", 0, 2).replyTo,
|
||||
).toBe("reply-1");
|
||||
expect(
|
||||
hoisted.sendMessageDiscordMock.mock.calls.map(
|
||||
(call) => (call[2] as { replyTo?: unknown } | undefined)?.replyTo,
|
||||
),
|
||||
).toEqual(["reply-1", "reply-1"]);
|
||||
});
|
||||
|
||||
it("keeps replyToId on every internal audioAsVoice send when replyToMode is all", async () => {
|
||||
await discordOutbound.sendPayload?.({
|
||||
cfg: {},
|
||||
|
||||
@@ -84,15 +84,13 @@ export async function sendDiscordOutboundPayload(params: {
|
||||
const sendContext = await createDiscordPayloadSendContext(ctx);
|
||||
|
||||
if (payload.audioAsVoice && mediaUrls.length > 0) {
|
||||
// audioAsVoice emits one logical Discord reply across voice/text/media sends.
|
||||
// Capture before helper calls consume implicit single-use reply targets.
|
||||
const voiceReplyTo = sendContext.resolveReplyTo();
|
||||
let lastResult = await sendContext.withRetry(
|
||||
async () =>
|
||||
await sendContext.sendVoice(sendContext.target, mediaUrls[0], {
|
||||
...resolveDiscordDeliveryOptions(ctx, sendContext),
|
||||
replyTo: voiceReplyTo,
|
||||
}),
|
||||
await sendContext.sendVoice(
|
||||
sendContext.target,
|
||||
mediaUrls[0],
|
||||
resolveDiscordDeliveryOptions(ctx, sendContext),
|
||||
),
|
||||
);
|
||||
if (payload.text?.trim()) {
|
||||
lastResult = await sendContext.withRetry(
|
||||
@@ -100,7 +98,6 @@ export async function sendDiscordOutboundPayload(params: {
|
||||
await sendContext.send(sendContext.target, payload.text, {
|
||||
verbose: false,
|
||||
...resolveDiscordFormattedDeliveryOptions(ctx, sendContext),
|
||||
replyTo: voiceReplyTo,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -110,7 +107,6 @@ export async function sendDiscordOutboundPayload(params: {
|
||||
await sendContext.send(sendContext.target, "", {
|
||||
verbose: false,
|
||||
...resolveDiscordMediaDeliveryOptions(ctx, sendContext, mediaUrl),
|
||||
replyTo: voiceReplyTo,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,35 +55,20 @@ describe("PDF document extractor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts text first and renders each fallback page with its own pixel budget", async () => {
|
||||
pdfDocument.extract
|
||||
.mockResolvedValueOnce({ text: "", images: [] })
|
||||
.mockResolvedValueOnce({
|
||||
text: "",
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
bytes: Uint8Array.from(Buffer.from("png1")),
|
||||
mimeType: "image/png",
|
||||
page: 1,
|
||||
width: 5,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
text: "",
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
bytes: Uint8Array.from(Buffer.from("png2")),
|
||||
mimeType: "image/png",
|
||||
page: 2,
|
||||
width: 5,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
it("extracts text first and renders fallback images through clawpdf", async () => {
|
||||
pdfDocument.extract.mockResolvedValueOnce({ text: "", images: [] }).mockResolvedValueOnce({
|
||||
text: "",
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
bytes: Uint8Array.from(Buffer.from("png")),
|
||||
mimeType: "image/png",
|
||||
page: 1,
|
||||
width: 10,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const extractor = createPdfDocumentExtractor();
|
||||
|
||||
const result = await extractor.extract(request());
|
||||
@@ -97,24 +82,18 @@ describe("PDF document extractor", () => {
|
||||
maxPages: 2,
|
||||
maxTextChars: 200_000,
|
||||
});
|
||||
// Each page renders in its own extract() call, with the aggregate pixel cap
|
||||
// allocated across selected pages so later pages are not starved.
|
||||
expect(pdfDocument.extract).toHaveBeenNthCalledWith(2, {
|
||||
mode: "images",
|
||||
pages: [1],
|
||||
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
|
||||
});
|
||||
expect(pdfDocument.extract).toHaveBeenNthCalledWith(3, {
|
||||
mode: "images",
|
||||
pages: [2],
|
||||
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
|
||||
maxPages: 2,
|
||||
image: {
|
||||
maxDimension: 10_000,
|
||||
maxPixels: 100,
|
||||
forms: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: "",
|
||||
images: [
|
||||
{ type: "image", data: "cG5nMQ==", mimeType: "image/png" },
|
||||
{ type: "image", data: "cG5nMg==", mimeType: "image/png" },
|
||||
],
|
||||
images: [{ type: "image", data: "cG5n", mimeType: "image/png" }],
|
||||
});
|
||||
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -152,9 +131,8 @@ describe("PDF document extractor", () => {
|
||||
expect(pdfDocument.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters selected pages and renders them one page per image call", async () => {
|
||||
it("filters selected pages before passing them to clawpdf", async () => {
|
||||
pdfDocument.extract
|
||||
.mockResolvedValueOnce({ text: "", images: [] })
|
||||
.mockResolvedValueOnce({ text: "", images: [] })
|
||||
.mockResolvedValueOnce({ text: "", images: [] });
|
||||
const extractor = createPdfDocumentExtractor();
|
||||
@@ -163,15 +141,11 @@ describe("PDF document extractor", () => {
|
||||
|
||||
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ mode: "text", pages: [2, 1] }),
|
||||
expect.objectContaining({ pages: [2, 1] }),
|
||||
);
|
||||
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ mode: "images", pages: [2] }),
|
||||
);
|
||||
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ mode: "images", pages: [1] }),
|
||||
expect.objectContaining({ pages: [2, 1] }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -83,38 +83,17 @@ async function extractPdfContent(
|
||||
return { text, images: [] };
|
||||
}
|
||||
|
||||
// clawpdf's image render budget (maxPixels) is shared across every page in one
|
||||
// extract() call: the first page consumes it and later pages collapse to 1x1
|
||||
// PNGs that vision models reject. Render each page separately, allocating the
|
||||
// remaining aggregate budget across pages that still need rendering.
|
||||
const imagePages =
|
||||
pages ?? Array.from({ length: Math.min(pdf.pageCount, request.maxPages) }, (_, i) => i + 1);
|
||||
|
||||
try {
|
||||
const images: DocumentExtractedImage[] = [];
|
||||
let remainingPixels = request.maxPixels;
|
||||
for (let index = 0; index < imagePages.length; index += 1) {
|
||||
if (remainingPixels <= 0) {
|
||||
break;
|
||||
}
|
||||
const pagesRemaining = imagePages.length - index;
|
||||
const maxPixelsPerPage = Math.max(1, Math.ceil(remainingPixels / pagesRemaining));
|
||||
const pageNumber = imagePages[index];
|
||||
const imageResult = await pdf.extract({
|
||||
mode: "images",
|
||||
pages: [pageNumber],
|
||||
image: {
|
||||
maxDimension: MAX_RENDER_DIMENSION,
|
||||
maxPixels: maxPixelsPerPage,
|
||||
forms: true,
|
||||
},
|
||||
});
|
||||
for (const image of imageResult.images) {
|
||||
images.push(toDocumentImage(image));
|
||||
remainingPixels -= image.width * image.height;
|
||||
}
|
||||
}
|
||||
return { text, images };
|
||||
const imageResult = await pdf.extract({
|
||||
mode: "images",
|
||||
...pageSelection,
|
||||
image: {
|
||||
maxDimension: MAX_RENDER_DIMENSION,
|
||||
maxPixels: request.maxPixels,
|
||||
forms: true,
|
||||
},
|
||||
});
|
||||
return { text, images: imageResult.images.map(toDocumentImage) };
|
||||
} catch (err) {
|
||||
request.onImageExtractionError?.(err);
|
||||
return { text, images: [] };
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// Elevenlabs provider module implements model/runtime integration.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { parseStrictFiniteNumber, parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import type {
|
||||
SpeechDirectiveTokenParseContext,
|
||||
@@ -370,14 +367,14 @@ async function listElevenLabsVoices(params: {
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowProviderError(response, "ElevenLabs voices API error");
|
||||
const json = await readProviderJsonResponse<{
|
||||
const json = (await response.json()) as {
|
||||
voices?: Array<{
|
||||
voice_id?: string;
|
||||
name?: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}>(response, "elevenlabs.voices");
|
||||
};
|
||||
return Array.isArray(json.voices)
|
||||
? json.voices
|
||||
.map((voice) => ({
|
||||
|
||||
@@ -75,16 +75,13 @@ function mockDiscoveryResponse(spec: {
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
}) {
|
||||
const status = spec.status ?? (spec.ok ? 200 : 500);
|
||||
const response =
|
||||
spec.json !== undefined
|
||||
? new Response(JSON.stringify(spec.json), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
: new Response(spec.text ?? "", { status });
|
||||
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
|
||||
response,
|
||||
response: {
|
||||
ok: spec.ok,
|
||||
status: spec.status ?? (spec.ok ? 200 : 500),
|
||||
json: async () => spec.json,
|
||||
text: async () => spec.text ?? "",
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
}
|
||||
@@ -231,16 +228,20 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
|
||||
it("wraps invalid discovery JSON as a setup error", async () => {
|
||||
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
|
||||
response: new Response("not-valid-json{{{", {
|
||||
response: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
json: async () => {
|
||||
throw new SyntaxError("bad json");
|
||||
},
|
||||
text: async () => "",
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
|
||||
).rejects.toThrow("github-copilot.model-discovery: malformed JSON response");
|
||||
).rejects.toThrow("GitHub Copilot model discovery returned invalid JSON");
|
||||
});
|
||||
|
||||
it("bounds model discovery error bodies", async () => {
|
||||
@@ -359,7 +360,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldContinueAutoSelection(
|
||||
new Error("github-copilot.model-discovery: malformed JSON response"),
|
||||
new Error("GitHub Copilot model discovery returned invalid JSON"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(shouldContinueAutoSelection(new Error("Network timeout"))).toBe(false);
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveFirstGithubToken } from "./auth.js";
|
||||
@@ -32,7 +29,6 @@ const COPILOT_HEADERS_STATIC: Record<string, string> = {
|
||||
...buildCopilotIdeHeaders(),
|
||||
};
|
||||
const COPILOT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
const COPILOT_EMBEDDINGS_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
function buildSsrfPolicy(baseUrl: string): SsrFPolicy | undefined {
|
||||
try {
|
||||
@@ -74,7 +70,6 @@ function isCopilotSetupError(err: unknown): boolean {
|
||||
err.message.includes("Copilot token response") ||
|
||||
err.message.includes("No embedding models available") ||
|
||||
err.message.includes("GitHub Copilot model discovery") ||
|
||||
err.message.includes("github-copilot.model-discovery") ||
|
||||
err.message.includes("GitHub Copilot embedding model") ||
|
||||
err.message.includes("Unexpected response from GitHub Copilot token endpoint")
|
||||
);
|
||||
@@ -105,7 +100,12 @@ async function discoverEmbeddingModels(params: {
|
||||
const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES);
|
||||
throw new Error(`GitHub Copilot model discovery HTTP ${response.status}: ${detail}`);
|
||||
}
|
||||
const payload = await readProviderJsonResponse(response, "github-copilot.model-discovery");
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new Error("GitHub Copilot model discovery returned invalid JSON");
|
||||
}
|
||||
const allModels = Array.isArray((payload as { data?: unknown })?.data)
|
||||
? ((payload as { data: CopilotModelEntry[] }).data ?? [])
|
||||
: [];
|
||||
@@ -246,9 +246,12 @@ async function createGitHubCopilotEmbeddingProvider(
|
||||
throw new Error(`GitHub Copilot embeddings HTTP ${response.status}: ${detail}`);
|
||||
}
|
||||
|
||||
const payload = await readProviderJsonResponse(response, "github-copilot.embeddings", {
|
||||
maxBytes: COPILOT_EMBEDDINGS_RESPONSE_MAX_BYTES,
|
||||
});
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new Error("GitHub Copilot embeddings returned invalid JSON");
|
||||
}
|
||||
return parseGitHubCopilotEmbeddingPayload(payload, input.length);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -267,47 +267,6 @@ describe("fetchCopilotUsage", () => {
|
||||
plan: "free",
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds the usage read and cancels the stream when the body exceeds the JSON byte cap", async () => {
|
||||
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels the
|
||||
// stream mid-flight; if the cap were removed the unbounded res.json() would buffer the whole body.
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const makeOversizedJsonResponse = (): Response => {
|
||||
let pulled = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (pulled >= TOTAL_CHUNKS) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
pulled += 1;
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const mockFetch = createProviderUsageFetch(async () => makeOversizedJsonResponse());
|
||||
|
||||
await expect(fetchCopilotUsage("token", 5000, mockFetch)).rejects.toThrow(
|
||||
/github-copilot-usage: JSON response exceeds/,
|
||||
);
|
||||
// The bounded reader cancels the body and never pulls the full advertised 32 MiB stream.
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
});
|
||||
});
|
||||
|
||||
describe("github-copilot token", () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Github Copilot plugin module implements usage behavior.
|
||||
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
buildUsageHttpErrorSnapshot,
|
||||
fetchJson,
|
||||
@@ -42,10 +41,7 @@ export async function fetchCopilotUsage(
|
||||
});
|
||||
}
|
||||
|
||||
const data = await readProviderJsonResponse<CopilotUsageResponse>(
|
||||
res,
|
||||
"github-copilot-usage",
|
||||
);
|
||||
const data = (await res.json()) as CopilotUsageResponse;
|
||||
const windows: UsageWindow[] = [];
|
||||
|
||||
if (data.quota_snapshots?.premium_interactions) {
|
||||
|
||||
@@ -94,39 +94,6 @@ function fetchInputUrl(fetchMock: ReturnType<typeof vi.fn>, index: number): stri
|
||||
return input.url;
|
||||
}
|
||||
|
||||
function oversizedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
const chunk = new Uint8Array(params.chunkSize);
|
||||
let readCount = 0;
|
||||
let canceled = false;
|
||||
return {
|
||||
response: new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (readCount >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
readCount += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
getReadCount: () => readCount,
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
let ssrfMock: { mockRestore: () => void } | undefined;
|
||||
|
||||
describe("google video generation provider", () => {
|
||||
@@ -519,33 +486,6 @@ describe("google video generation provider", () => {
|
||||
expect(result.videos[0]?.buffer).toEqual(Buffer.from("rest-video"));
|
||||
});
|
||||
|
||||
it("bounds successful Google REST operation JSON bodies instead of buffering the whole response", async () => {
|
||||
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "google-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
generateVideosMock.mockRejectedValue(Object.assign(new Error("sdk 404"), { status: 404 }));
|
||||
const streamed = oversizedJsonResponse({ chunkCount: 64, chunkSize: 1024 * 1024 });
|
||||
const fetchMock = vi.fn(async () => streamed.response);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildGoogleVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "google",
|
||||
model: "veo-3.1-fast-generate-preview",
|
||||
prompt: "A tiny robot watering a windowsill garden",
|
||||
cfg: {},
|
||||
durationSeconds: 3,
|
||||
}),
|
||||
).rejects.toThrow("Google video operation response exceeds 16777216 bytes");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(streamed.getReadCount()).toBeLessThan(64);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
});
|
||||
|
||||
it("retries transient Google REST poll failures with empty bodies", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
|
||||
@@ -28,7 +28,6 @@ const DEFAULT_TIMEOUT_MS = 180_000;
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const MAX_POLL_ATTEMPTS = 120;
|
||||
const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const GOOGLE_VIDEO_OPERATION_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const GOOGLE_VIDEO_EMPTY_RESULT_MESSAGE =
|
||||
"Google video generation response missing generated videos";
|
||||
|
||||
@@ -350,15 +349,7 @@ async function requestGoogleVideoJson(params: {
|
||||
signal: controller.signal,
|
||||
});
|
||||
try {
|
||||
const buffer = await readResponseWithLimit(
|
||||
response,
|
||||
GOOGLE_VIDEO_OPERATION_RESPONSE_MAX_BYTES,
|
||||
{
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`Google video operation response exceeds ${maxBytes} bytes`),
|
||||
},
|
||||
);
|
||||
const text = new TextDecoder().decode(buffer);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let detail: unknown = text;
|
||||
if (text) {
|
||||
|
||||
@@ -49,15 +49,6 @@ describe("sanitizeOutboundText", () => {
|
||||
expect(result).not.toMatch(/^assistant:$/m);
|
||||
});
|
||||
|
||||
it("preserves prose lines that merely end with 'user:'/'system:'", () => {
|
||||
expect(sanitizeOutboundText("Please send this reply to the user:")).toBe(
|
||||
"Please send this reply to the user:",
|
||||
);
|
||||
expect(sanitizeOutboundText("Here is a note for the system:")).toBe(
|
||||
"Here is a note for the system:",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses excessive blank lines after stripping", () => {
|
||||
const text = "Hello\n\n\n\n\nWorld";
|
||||
expect(sanitizeOutboundText(text)).toBe("Hello\n\nWorld");
|
||||
|
||||
@@ -7,9 +7,7 @@ import { stripAssistantInternalScaffolding } from "openclaw/plugin-sdk/text-chun
|
||||
*/
|
||||
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g;
|
||||
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi;
|
||||
// Only a standalone role marker on its own line (a leaked turn boundary) — not
|
||||
// any line that merely ends with the word "user/system/assistant:" in prose.
|
||||
const ROLE_TURN_MARKER_RE = /^[ \t]*(?:user|system|assistant)\s*:\s*$/gm;
|
||||
const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm;
|
||||
|
||||
/**
|
||||
* Strip all assistant-internal scaffolding from outbound text before delivery.
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
generateSecMsGecToken,
|
||||
} from "node-edge-tts/dist/drm.js";
|
||||
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
captureHttpExchange,
|
||||
isDebugProxyGlobalFetchPatchInstalled,
|
||||
@@ -169,10 +166,7 @@ export async function listMicrosoftVoices(): Promise<SpeechVoiceOption[]> {
|
||||
});
|
||||
}
|
||||
await assertOkOrThrowProviderError(response, "Microsoft voices API error");
|
||||
const voices = await readProviderJsonResponse<MicrosoftVoiceListEntry[]>(
|
||||
response,
|
||||
"microsoft.speech-voices",
|
||||
);
|
||||
const voices = (await response.json()) as MicrosoftVoiceListEntry[];
|
||||
return Array.isArray(voices)
|
||||
? voices
|
||||
.map((voice) => ({
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Minimax plugin module implements tts behavior.
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
@@ -108,10 +105,10 @@ export async function minimaxTTS(params: {
|
||||
try {
|
||||
await assertOkOrThrowProviderError(response, "MiniMax TTS API error");
|
||||
|
||||
const body = await readProviderJsonResponse<{
|
||||
const body = (await response.json()) as {
|
||||
data?: { audio?: string };
|
||||
base_resp?: { status_code?: number; status_msg?: string };
|
||||
}>(response, "minimax.tts");
|
||||
};
|
||||
|
||||
// Check base_resp for envelope errors (HTTP 200 with non-zero status_code).
|
||||
// Other MiniMax providers (image, video, music, web-search) already check this.
|
||||
@@ -122,7 +119,9 @@ export async function minimaxTTS(params: {
|
||||
body.base_resp.status_code !== 0
|
||||
) {
|
||||
const msg = body.base_resp.status_msg ?? "unknown error";
|
||||
throw new Error(`MiniMax TTS API error (${body.base_resp.status_code}): ${msg}`);
|
||||
throw new Error(
|
||||
`MiniMax TTS API error (${body.base_resp.status_code}): ${msg}`,
|
||||
);
|
||||
}
|
||||
|
||||
const hexAudio = body?.data?.audio;
|
||||
|
||||
@@ -24,8 +24,6 @@ const { assertOkOrThrowHttpErrorMock, postJsonRequestMock, resolveProviderHttpRe
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
|
||||
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
|
||||
postJsonRequest: postJsonRequestMock,
|
||||
// Pass-through: bounded-reader enforcement is tested via bounded-reader unit tests.
|
||||
readProviderJsonResponse: async (response: { json(): Promise<unknown> }) => response.json(),
|
||||
requireTranscriptionText: (value: string | undefined, message: string) => {
|
||||
const text = value?.trim();
|
||||
if (!text) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
requireTranscriptionText,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
@@ -149,10 +148,7 @@ export async function transcribeOpenRouterAudio(
|
||||
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "OpenRouter audio transcription failed");
|
||||
const payload = await readProviderJsonResponse<OpenRouterSttResponse>(
|
||||
response,
|
||||
"openrouter.stt",
|
||||
);
|
||||
const payload = (await response.json()) as OpenRouterSttResponse;
|
||||
return {
|
||||
text: requireTranscriptionText(
|
||||
payload.text,
|
||||
|
||||
@@ -54,31 +54,10 @@ vi.mock("openclaw/plugin-sdk/provider-http", async () => {
|
||||
|
||||
function releasedJson(value: unknown) {
|
||||
return {
|
||||
response: new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function releasedOversizedJsonStream() {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(16 * 1024 * 1024 + 1));
|
||||
response: {
|
||||
json: async () => value,
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,40 +292,6 @@ describe("openrouter video generation provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels oversized OpenRouter video catalog success bodies", async () => {
|
||||
const oversized = releasedOversizedJsonStream();
|
||||
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(oversized);
|
||||
|
||||
await expect(
|
||||
listOpenRouterVideoModelCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openrouter: {
|
||||
baseUrl: "https://custom.openrouter.test/openrouter/api/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "OPENROUTER_API_KEY",
|
||||
discoveryApiKey: "resolved-openrouter-key",
|
||||
}),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "OPENROUTER_API_KEY",
|
||||
discoveryApiKey: "resolved-openrouter-key",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"OpenRouter video models request failed: JSON response exceeds 16777216 bytes",
|
||||
);
|
||||
expect(oversized.wasCanceled()).toBe(true);
|
||||
expect(oversized.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips live OpenRouter video catalog discovery without an API key", async () => {
|
||||
await expect(
|
||||
listOpenRouterVideoModelCatalog({
|
||||
|
||||
@@ -7,7 +7,6 @@ import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runt
|
||||
import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
@@ -235,10 +234,7 @@ async function fetchOpenRouterVideoModels(params: {
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "OpenRouter video models request failed");
|
||||
return await readProviderJsonResponse<OpenRouterVideoModelsResponse>(
|
||||
response,
|
||||
"OpenRouter video models request failed",
|
||||
);
|
||||
return (await response.json()) as OpenRouterVideoModelsResponse;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
// Openshell tests cover backend-owned exec workdir validation behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { CreateSandboxBackendParams } from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
createSandboxBrowserConfig,
|
||||
createSandboxPruneConfig,
|
||||
createSandboxSshConfig,
|
||||
} from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createOpenShellSandboxBackendFactory } from "./backend.js";
|
||||
import { resolveOpenShellPluginConfig } from "./config.js";
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
runSshSandboxCommand: vi.fn(),
|
||||
disposeSshSandboxSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const cliMocks = vi.hoisted(() => ({
|
||||
runOpenShellCli: vi.fn(),
|
||||
createOpenShellSshSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/sandbox", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/sandbox")>();
|
||||
return {
|
||||
...actual,
|
||||
runSshSandboxCommand: sdkMocks.runSshSandboxCommand,
|
||||
disposeSshSandboxSession: sdkMocks.disposeSshSandboxSession,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./cli.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./cli.js")>();
|
||||
return {
|
||||
...actual,
|
||||
runOpenShellCli: cliMocks.runOpenShellCli,
|
||||
createOpenShellSshSession: cliMocks.createOpenShellSshSession,
|
||||
};
|
||||
});
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createOpenShellBackendSandboxConfig(): CreateSandboxBackendParams["cfg"] {
|
||||
return {
|
||||
mode: "all",
|
||||
backend: "openshell",
|
||||
scope: "session",
|
||||
workspaceAccess: "rw",
|
||||
workspaceRoot: "/tmp/openclaw-sandboxes",
|
||||
docker: {
|
||||
image: "openclaw-sandbox:bookworm-slim",
|
||||
containerPrefix: "openclaw-sbx-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: false,
|
||||
tmpfs: [],
|
||||
network: "none",
|
||||
capDrop: [],
|
||||
binds: [],
|
||||
env: {},
|
||||
},
|
||||
ssh: createSandboxSshConfig("/tmp/openclaw-sandboxes"),
|
||||
browser: createSandboxBrowserConfig(),
|
||||
tools: { allow: ["*"], deny: [] },
|
||||
prune: createSandboxPruneConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("openshell backend exec workdir validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cliMocks.createOpenShellSshSession.mockResolvedValue({
|
||||
command: "ssh",
|
||||
configPath: "/tmp/openclaw-openshell-test-ssh-config",
|
||||
host: "openshell-test",
|
||||
});
|
||||
cliMocks.runOpenShellCli.mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
sdkMocks.runSshSandboxCommand.mockImplementation(async ({ remoteCommand }) => ({
|
||||
stdout: String(remoteCommand).includes("openclaw-validate-workdir")
|
||||
? Buffer.from("/workspace\n")
|
||||
: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses validation-time workspace preparation for the following exec", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
|
||||
await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed", "utf8");
|
||||
const backendFactory = createOpenShellSandboxBackendFactory({
|
||||
pluginConfig: resolveOpenShellPluginConfig({
|
||||
command: "openshell",
|
||||
mode: "mirror",
|
||||
}),
|
||||
});
|
||||
const backend = await backendFactory({
|
||||
sessionKey: "agent:main:turn",
|
||||
scopeKey: "agent:main",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg: createOpenShellBackendSandboxConfig(),
|
||||
});
|
||||
|
||||
await expect(backend.validateWorkdir?.("/workspace")).resolves.toBe("/workspace");
|
||||
const execSpec = await backend.buildExecSpec({
|
||||
command: "pwd",
|
||||
workdir: "/workspace",
|
||||
env: {},
|
||||
usePty: false,
|
||||
});
|
||||
|
||||
const uploadCalls = cliMocks.runOpenShellCli.mock.calls.filter(
|
||||
([params]) => params.args[0] === "sandbox" && params.args[1] === "upload",
|
||||
);
|
||||
expect(uploadCalls).toHaveLength(1);
|
||||
expect(execSpec.argv).toContain("openshell-test");
|
||||
});
|
||||
|
||||
it("does not reuse validation-time workspace preparation after discard", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
|
||||
await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed", "utf8");
|
||||
const backendFactory = createOpenShellSandboxBackendFactory({
|
||||
pluginConfig: resolveOpenShellPluginConfig({
|
||||
command: "openshell",
|
||||
mode: "mirror",
|
||||
}),
|
||||
});
|
||||
const backend = await backendFactory({
|
||||
sessionKey: "agent:main:turn",
|
||||
scopeKey: "agent:main",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg: createOpenShellBackendSandboxConfig(),
|
||||
});
|
||||
|
||||
await expect(backend.validateWorkdir?.("/workspace")).resolves.toBe("/workspace");
|
||||
backend.discardPreparedWorkdir?.("/workspace");
|
||||
await backend.buildExecSpec({
|
||||
command: "pwd",
|
||||
workdir: "/workspace",
|
||||
env: {},
|
||||
usePty: false,
|
||||
});
|
||||
|
||||
const uploadCalls = cliMocks.runOpenShellCli.mock.calls.filter(
|
||||
([params]) => params.args[0] === "sandbox" && params.args[1] === "upload",
|
||||
);
|
||||
expect(uploadCalls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer
|
||||
import type { OpenShellSandboxBackend } from "./backend.types.js";
|
||||
import {
|
||||
buildValidatedExecRemoteCommand,
|
||||
buildRemoteWorkdirValidationCommand,
|
||||
buildRemoteCommand,
|
||||
createOpenShellSshSession,
|
||||
runOpenShellCli,
|
||||
@@ -281,13 +280,6 @@ async function createOpenShellSandboxBackend(params: {
|
||||
mode: params.pluginConfig.mode,
|
||||
configLabel: params.pluginConfig.from,
|
||||
configLabelKind: "Source",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir: async (workdir) => await impl.validateWorkdir(workdir),
|
||||
discardPreparedWorkdir: (workdir) => impl.discardPreparedWorkdir(workdir),
|
||||
workdirRoots: [
|
||||
params.pluginConfig.remoteWorkspaceDir,
|
||||
params.pluginConfig.remoteAgentWorkspaceDir,
|
||||
],
|
||||
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
||||
const pending = await impl.prepareExec({ command, workdir, env, usePty });
|
||||
return {
|
||||
@@ -326,10 +318,6 @@ async function createOpenShellSandboxBackend(params: {
|
||||
|
||||
class OpenShellSandboxBackendImpl {
|
||||
private ensurePromise: Promise<void> | null = null;
|
||||
private preparedRemoteWorkspaceForNextExec: {
|
||||
workdir: string;
|
||||
promise: Promise<void>;
|
||||
} | null = null;
|
||||
private remoteSeedPending = false;
|
||||
|
||||
constructor(
|
||||
@@ -351,10 +339,6 @@ class OpenShellSandboxBackendImpl {
|
||||
mode: this.params.execContext.config.mode,
|
||||
configLabel: this.params.execContext.config.from,
|
||||
configLabelKind: "Source",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir: async (workdir) => await this.validateWorkdir(workdir),
|
||||
discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir),
|
||||
workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir],
|
||||
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
|
||||
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
|
||||
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
||||
@@ -398,14 +382,20 @@ class OpenShellSandboxBackendImpl {
|
||||
env: Record<string, string>;
|
||||
usePty: boolean;
|
||||
}): Promise<{ argv: string[]; token: PendingExec }> {
|
||||
const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
|
||||
const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
|
||||
const remoteCommand = buildValidatedExecRemoteCommand({
|
||||
command: params.command,
|
||||
workdir: remoteWorkdir,
|
||||
workdir: params.workdir ?? this.params.remoteWorkspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
await (preparedWorkspace ?? this.prepareRemoteWorkspaceForExec());
|
||||
await this.ensureSandboxExists();
|
||||
if (this.params.execContext.config.mode === "mirror") {
|
||||
await this.syncWorkspaceToRemote();
|
||||
} else {
|
||||
const seeded = await this.maybeSeedRemoteWorkspace();
|
||||
if (!seeded) {
|
||||
await this.syncSkillsWorkspaceToRemote();
|
||||
}
|
||||
}
|
||||
const sshSession = await createOpenShellSshSession({
|
||||
context: this.params.execContext,
|
||||
});
|
||||
@@ -424,85 +414,6 @@ class OpenShellSandboxBackendImpl {
|
||||
};
|
||||
}
|
||||
|
||||
async validateWorkdir(workdir: string): Promise<string | null> {
|
||||
const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
|
||||
const reusablePreparation = { workdir, promise: preparedWorkspace };
|
||||
this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
|
||||
try {
|
||||
await preparedWorkspace;
|
||||
const sshSession = await createOpenShellSshSession({
|
||||
context: this.params.execContext,
|
||||
});
|
||||
try {
|
||||
const result = await runSshSandboxCommand({
|
||||
session: sshSession,
|
||||
remoteCommand: buildRemoteWorkdirValidationCommand({
|
||||
workdir,
|
||||
root: this.resolveWorkdirValidationRoot(workdir),
|
||||
}),
|
||||
allowFailure: true,
|
||||
});
|
||||
const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : "";
|
||||
if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) {
|
||||
this.preparedRemoteWorkspaceForNextExec = resolvedWorkdir
|
||||
? { workdir: resolvedWorkdir, promise: preparedWorkspace }
|
||||
: null;
|
||||
}
|
||||
return resolvedWorkdir || null;
|
||||
} finally {
|
||||
await disposeSshSandboxSession(sshSession);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) {
|
||||
this.preparedRemoteWorkspaceForNextExec = null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveWorkdirValidationRoot(workdir: string): string {
|
||||
try {
|
||||
const normalized = normalizeRemotePath(workdir);
|
||||
const roots = [
|
||||
normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
|
||||
normalizeRemotePath(this.params.remoteWorkspaceDir),
|
||||
].toSorted((a, b) => b.length - a.length);
|
||||
return (
|
||||
roots.find((root) => isRemotePathInside(root, normalized)) ?? this.params.remoteWorkspaceDir
|
||||
);
|
||||
} catch {
|
||||
return this.params.remoteWorkspaceDir;
|
||||
}
|
||||
}
|
||||
|
||||
private consumePreparedRemoteWorkspaceForNextExec(workdir: string): Promise<void> | null {
|
||||
const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
|
||||
if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) {
|
||||
this.preparedRemoteWorkspaceForNextExec = null;
|
||||
return null;
|
||||
}
|
||||
this.preparedRemoteWorkspaceForNextExec = null;
|
||||
return preparedWorkspace.promise;
|
||||
}
|
||||
|
||||
discardPreparedWorkdir(workdir: string): void {
|
||||
if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) {
|
||||
this.preparedRemoteWorkspaceForNextExec = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareRemoteWorkspaceForExec(): Promise<void> {
|
||||
await this.ensureSandboxExists();
|
||||
if (this.params.execContext.config.mode === "mirror") {
|
||||
await this.syncWorkspaceToRemote();
|
||||
return;
|
||||
}
|
||||
const seeded = await this.maybeSeedRemoteWorkspace();
|
||||
if (!seeded) {
|
||||
await this.syncSkillsWorkspaceToRemote();
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeExec(token?: PendingExec): Promise<void> {
|
||||
try {
|
||||
if (this.params.execContext.config.mode === "mirror") {
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { ResolvedOpenShellPluginConfig } from "./config.js";
|
||||
|
||||
export {
|
||||
buildExecRemoteCommand,
|
||||
buildRemoteWorkdirValidationCommand,
|
||||
buildValidatedExecRemoteCommand,
|
||||
shellEscape,
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
@@ -168,42 +168,23 @@ describe("runtime parity", () => {
|
||||
const scoped = __testing.filterMockRequestsForParentPrompt(
|
||||
[
|
||||
{
|
||||
prompt: "Fanout worker alpha: inspect the QA workspace and finish with exactly ALPHA-OK.",
|
||||
allInputText:
|
||||
"Delegate one bounded QA task to a subagent. Fanout worker alpha: inspect the QA workspace and finish with exactly ALPHA-OK.",
|
||||
plannedToolName: "read",
|
||||
},
|
||||
{
|
||||
prompt: "Delegate one bounded QA task to a subagent.",
|
||||
allInputText: "Delegate one bounded QA task to a subagent.",
|
||||
plannedToolName: "sessions_spawn",
|
||||
},
|
||||
{
|
||||
prompt: "Continue the bounded QA task with the retained child result.",
|
||||
allInputText:
|
||||
"Delegate one bounded QA task to a subagent. Continue the bounded QA task with the retained child result.",
|
||||
plannedToolName: "sessions_spawn",
|
||||
},
|
||||
{
|
||||
allInputText: "Inspect the QA workspace and return one concise protocol note.",
|
||||
plannedToolName: "read",
|
||||
},
|
||||
{
|
||||
prompt: "Delegate one bounded QA task to a subagent.",
|
||||
allInputText: "Delegate one bounded QA task to a subagent. Tool result: child accepted.",
|
||||
toolOutput: "child accepted",
|
||||
},
|
||||
],
|
||||
"Delegate one bounded QA task to a subagent.",
|
||||
[
|
||||
"Delegate one bounded QA task to a subagent.",
|
||||
"Continue the bounded QA task with the retained child result.",
|
||||
],
|
||||
);
|
||||
|
||||
expect(scoped).toHaveLength(3);
|
||||
expect(scoped).toHaveLength(2);
|
||||
expect(scoped.map((request) => request.plannedToolName ?? "result")).toEqual([
|
||||
"sessions_spawn",
|
||||
"sessions_spawn",
|
||||
"result",
|
||||
]);
|
||||
|
||||
@@ -120,7 +120,6 @@ type RuntimeParityTranscriptRecord = {
|
||||
};
|
||||
|
||||
type RuntimeParityMockRequestSnapshot = {
|
||||
prompt?: string;
|
||||
allInputText?: string;
|
||||
plannedToolName?: string;
|
||||
plannedToolArgs?: unknown;
|
||||
@@ -760,22 +759,14 @@ function resolveRuntimeParityToolCalls(params: {
|
||||
function filterMockRequestsForParentPrompt(
|
||||
requests: RuntimeParityMockRequestSnapshot[],
|
||||
parentPrompt: string,
|
||||
parentPrompts: readonly string[] = [parentPrompt],
|
||||
) {
|
||||
const normalizedParentPrompts = parentPrompts
|
||||
.map(normalizeTextForParity)
|
||||
.filter((prompt) => prompt.length > 0);
|
||||
if (normalizedParentPrompts.length === 0) {
|
||||
const normalizedParentPrompt = normalizeTextForParity(parentPrompt);
|
||||
if (!normalizedParentPrompt) {
|
||||
return requests;
|
||||
}
|
||||
const matching = requests.filter((request) => {
|
||||
const normalizedPrompt = normalizeTextForParity(request.prompt ?? "");
|
||||
if (normalizedPrompt) {
|
||||
return normalizedParentPrompts.some((prompt) => normalizedPrompt.includes(prompt));
|
||||
}
|
||||
const normalizedHistory = normalizeTextForParity(request.allInputText ?? "");
|
||||
return normalizedParentPrompts.some((prompt) => normalizedHistory.includes(prompt));
|
||||
});
|
||||
const matching = requests.filter((request) =>
|
||||
normalizeTextForParity(request.allInputText ?? "").includes(normalizedParentPrompt),
|
||||
);
|
||||
return matching.length > 0 ? matching : requests;
|
||||
}
|
||||
|
||||
@@ -975,7 +966,6 @@ async function loadRuntimeParityTranscripts(params: {
|
||||
async function loadRuntimeParityMockToolCalls(
|
||||
mockBaseUrl: string | undefined,
|
||||
parentPrompt: string,
|
||||
parentPrompts: readonly string[] = [parentPrompt],
|
||||
): Promise<RuntimeParityToolCall[] | null> {
|
||||
const normalizedBaseUrl = mockBaseUrl?.trim().replace(/\/+$/u, "");
|
||||
if (!normalizedBaseUrl) {
|
||||
@@ -1001,7 +991,6 @@ async function loadRuntimeParityMockToolCalls(
|
||||
}
|
||||
const requests = payload.filter(isMessageRecord).map(
|
||||
(entry): RuntimeParityMockRequestSnapshot => ({
|
||||
prompt: readNonEmptyString(entry.prompt),
|
||||
allInputText: readNonEmptyString(entry.allInputText),
|
||||
plannedToolName: readNonEmptyString(entry.plannedToolName),
|
||||
plannedToolArgs: entry.plannedToolArgs ?? null,
|
||||
@@ -1009,7 +998,7 @@ async function loadRuntimeParityMockToolCalls(
|
||||
}),
|
||||
);
|
||||
return resolveToolCallOrderFromMockRequests(
|
||||
filterMockRequestsForParentPrompt(requests, parentPrompt, parentPrompts),
|
||||
filterMockRequestsForParentPrompt(requests, parentPrompt),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -1026,16 +1015,12 @@ export async function captureRuntimeParityCell(
|
||||
});
|
||||
const transcriptRecords = buildTranscriptRecords(transcriptBytes);
|
||||
const transcriptToolCalls = resolveToolCallOrder(transcriptRecords);
|
||||
const parentPrompts = transcriptRecords
|
||||
.filter((record) => record.role === "user")
|
||||
.map((record) => extractAssistantText(record.message))
|
||||
.filter((prompt) => prompt.length > 0);
|
||||
const parentPrompt = parentPrompts[0] ?? "";
|
||||
const mockToolCalls = await loadRuntimeParityMockToolCalls(
|
||||
params.mockBaseUrl,
|
||||
parentPrompt,
|
||||
parentPrompts,
|
||||
);
|
||||
const parentPrompt =
|
||||
transcriptRecords
|
||||
.filter((record) => record.role === "user" && !isToolResultLikeMessage(record.message))
|
||||
.map((record) => extractAssistantText(record.message))
|
||||
.find(Boolean) ?? "";
|
||||
const mockToolCalls = await loadRuntimeParityMockToolCalls(params.mockBaseUrl, parentPrompt);
|
||||
const gatewayLogs = params.gateway.logs?.();
|
||||
const sentinelFindings = [
|
||||
...scanGatewayLogSentinels(gatewayLogs),
|
||||
|
||||
@@ -8,39 +8,6 @@ import { describeQwenVideo } from "./media-understanding-provider.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
function oversizedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
const chunk = new Uint8Array(params.chunkSize);
|
||||
let readCount = 0;
|
||||
let canceled = false;
|
||||
return {
|
||||
response: new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (readCount >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
readCount += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
getReadCount: () => readCount,
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
describe("describeQwenVideo", () => {
|
||||
it("builds the expected OpenAI-compatible video payload", async () => {
|
||||
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({
|
||||
@@ -107,42 +74,4 @@ describe("describeQwenVideo", () => {
|
||||
`data:video/mp4;base64,${Buffer.from("video-bytes").toString("base64")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds successful Qwen video JSON bodies instead of buffering the whole response", async () => {
|
||||
const streamed = oversizedJsonResponse({ chunkCount: 64, chunkSize: 1024 * 1024 });
|
||||
|
||||
await expect(
|
||||
describeQwenVideo({
|
||||
buffer: Buffer.from("video-bytes"),
|
||||
fileName: "clip.mp4",
|
||||
mime: "video/mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
baseUrl: "https://example.com/v1",
|
||||
fetchFn: async () => streamed.response,
|
||||
}),
|
||||
).rejects.toThrow("Qwen video description failed: JSON response exceeds 16777216 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(64);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
});
|
||||
|
||||
it("reports malformed Qwen video JSON with a provider-owned error", async () => {
|
||||
const response = new Response("not-json{", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
describeQwenVideo({
|
||||
buffer: Buffer.from("video-bytes"),
|
||||
fileName: "clip.mp4",
|
||||
mime: "video/mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
baseUrl: "https://example.com/v1",
|
||||
fetchFn: async () => response,
|
||||
}),
|
||||
).rejects.toThrow("Qwen video description failed: malformed JSON response");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { QWEN_STANDARD_GLOBAL_BASE_URL } from "./models.js";
|
||||
@@ -61,14 +60,7 @@ export async function describeQwenVideo(
|
||||
|
||||
try {
|
||||
await assertOkOrThrowHttpError(res, "Qwen video description failed");
|
||||
// Read the success body through the shared byte-bounded JSON reader (16 MiB cap +
|
||||
// stream cancel on overflow) so a hostile or buggy endpoint cannot force the runtime
|
||||
// to buffer an unbounded body. Malformed JSON keeps the
|
||||
// `Qwen video description failed: malformed JSON response` wrapping.
|
||||
const payload = await readProviderJsonResponse<OpenAiCompatibleVideoPayload>(
|
||||
res,
|
||||
"Qwen video description failed",
|
||||
);
|
||||
const payload = (await res.json()) as OpenAiCompatibleVideoPayload;
|
||||
const text = coerceOpenAiCompatibleVideoText(payload);
|
||||
if (!text) {
|
||||
throw new Error("Qwen video description response missing content");
|
||||
|
||||
@@ -197,7 +197,6 @@ export const signalApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda
|
||||
conversationKey: entry.conversationKey,
|
||||
messageId: entry.messageId,
|
||||
approvalId: request.id,
|
||||
approvalKind: view.approvalKind,
|
||||
allowedDecisions: pendingPayload.reactionPayload.allowedDecisions,
|
||||
targetAuthorKeys: entry.targetAuthorKeys,
|
||||
route: {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
buildPluginApprovalPendingReplyPayload,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
// Signal tests cover approval reactions plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSignalApprovalReactionHintToText,
|
||||
addSignalApprovalReactionHintToStructuredPayload,
|
||||
appendSignalApprovalReactionHintForOutboundMessage,
|
||||
buildSignalApprovalReactionHint,
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
maybeResolveSignalApprovalReaction,
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload,
|
||||
registerSignalApprovalReactionTargetForOutboundMessage,
|
||||
registerSignalApprovalReactionTarget,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
@@ -82,220 +78,7 @@ describe("Signal approval reactions", () => {
|
||||
).toBe(prompt);
|
||||
});
|
||||
|
||||
it("registers delivered structured approval payloads for reactions", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-structured-approval",
|
||||
approvalSlug: "exec-str",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000012",
|
||||
toJid: "+15551230000",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000012",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-structured-approval",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not register metadata-only approval payloads without visible reaction hints", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-hidden-reaction",
|
||||
approvalSlug: "exec-hid",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf hidden",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000015",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000015",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("registers only delivered chunks that contain visible reaction hints", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-chunked-reaction",
|
||||
approvalSlug: "exec-ch",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf chunked",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000016",
|
||||
meta: {
|
||||
signalVisibleText: "Exec approval required\n\nReact with:\n\n👍 Allow Once\n👎 Deny",
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000017",
|
||||
meta: {
|
||||
signalVisibleText: "Continuation chunk without controls",
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000016",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "exec-chunked-reaction",
|
||||
decision: "allow-once",
|
||||
});
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000017",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("registers delivered structured plugin approval payloads using metadata kind", async () => {
|
||||
it("registers target-mode outbound approval prompts for reactions", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
@@ -310,106 +93,70 @@ describe("Signal approval reactions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildPluginApprovalPendingReplyPayload({
|
||||
request: {
|
||||
id: "plugin-structured-approval",
|
||||
request: {
|
||||
title: "Sensitive plugin action",
|
||||
description: "Needs approval",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
},
|
||||
createdAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
},
|
||||
nowMs: 1_000,
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
const text =
|
||||
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny";
|
||||
const textWithHint = appendSignalApprovalReactionHintForOutboundMessage({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
text,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(textWithHint).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
registerSignalApprovalReactionTargetForOutboundMessage({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000013",
|
||||
},
|
||||
],
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
messageId: "1700000000009",
|
||||
text: textWithHint,
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000013",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "plugin-structured-approval",
|
||||
approvalKind: "plugin",
|
||||
const handled = await maybeResolveSignalApprovalReaction({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000009",
|
||||
reactionKey: "👍",
|
||||
actorId: "+15551230000",
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(resolverMocks.resolveSignalApproval).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
approvalId: "plugin:abc",
|
||||
decision: "allow-once",
|
||||
senderId: "+15551230000",
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not register delivered structured approval payloads without explicit approvers", () => {
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-no-approvers",
|
||||
approvalSlug: "exec-no",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
});
|
||||
const deliveredPayload = {
|
||||
...payload,
|
||||
text: addSignalApprovalReactionHintToText({
|
||||
text: payload.text ?? "",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
}),
|
||||
};
|
||||
it("keeps target-mode outbound prompts manual when the target route is disabled", () => {
|
||||
const text =
|
||||
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny";
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
appendSignalApprovalReactionHintForOutboundMessage({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {},
|
||||
},
|
||||
channels: { signal: { allowFrom: ["+15551230000"] } },
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
plugin: {
|
||||
enabled: false,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000014",
|
||||
},
|
||||
],
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
text,
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(false);
|
||||
).toBe(text);
|
||||
});
|
||||
|
||||
it("registers reaction state when only allow-always is available", async () => {
|
||||
|
||||
@@ -8,12 +8,8 @@ import {
|
||||
type ApprovalReactionDecisionBinding,
|
||||
type ApprovalReactionTargetRecord,
|
||||
} from "openclaw/plugin-sdk/approval-reaction-runtime";
|
||||
import {
|
||||
getExecApprovalReplyMetadata,
|
||||
type ExecApprovalReplyDecision,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -25,7 +21,7 @@ import { looksLikeUuid } from "./identity.js";
|
||||
import { normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
import { getOptionalSignalRuntime } from "./runtime.js";
|
||||
|
||||
const PERSISTENT_NAMESPACE = "signal.approval-reactions.v2";
|
||||
const PERSISTENT_NAMESPACE = "signal.approval-reactions";
|
||||
const PERSISTENT_MAX_ENTRIES = 1000;
|
||||
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -62,19 +58,6 @@ type SignalApprovalReactionTarget = ApprovalReactionTargetRecord<SignalApprovalR
|
||||
route: SignalApprovalReactionRoute;
|
||||
};
|
||||
|
||||
type SignalApprovalDeliveryTarget = {
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
type SignalApprovalDeliveryResult = {
|
||||
channel?: string;
|
||||
messageId?: string | null;
|
||||
toJid?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let resolverRuntimePromise: Promise<typeof import("./approval-resolver.js")> | undefined;
|
||||
|
||||
const signalApprovalReactionTargets =
|
||||
@@ -337,7 +320,7 @@ export function addSignalApprovalReactionHintToText(params: {
|
||||
text: string;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
}): string {
|
||||
if (hasSignalApprovalReactionHintText(params.text)) {
|
||||
if (/(^|\n)React with:\s*(\n|$)/i.test(params.text)) {
|
||||
return params.text;
|
||||
}
|
||||
const hint = buildSignalApprovalReactionHint(params.allowedDecisions);
|
||||
@@ -346,8 +329,40 @@ export function addSignalApprovalReactionHintToText(params: {
|
||||
: params.text;
|
||||
}
|
||||
|
||||
function hasSignalApprovalReactionHintText(text?: string | null): boolean {
|
||||
return /(^|\n)React with:\s*(\n|$)/i.test(text ?? "");
|
||||
function normalizeApprovalDecision(value: string): ExecApprovalReplyDecision | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "always") {
|
||||
return "allow-always";
|
||||
}
|
||||
if (normalized === "allow-once" || normalized === "allow-always" || normalized === "deny") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractSignalApprovalPromptBinding(text: string): {
|
||||
approvalId: string;
|
||||
allowedDecisions: ExecApprovalReplyDecision[];
|
||||
} | null {
|
||||
const allowedDecisions: ExecApprovalReplyDecision[] = [];
|
||||
let approvalId = "";
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
if (approvalId && match[1] !== approvalId) {
|
||||
continue;
|
||||
}
|
||||
approvalId ||= match[1];
|
||||
for (const decisionText of match[2].split(/[\s|,]+/)) {
|
||||
const decision = normalizeApprovalDecision(decisionText);
|
||||
if (decision && !allowedDecisions.includes(decision)) {
|
||||
allowedDecisions.push(decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
return approvalId && allowedDecisions.length > 0 ? { approvalId, allowedDecisions } : null;
|
||||
}
|
||||
|
||||
function buildTargetRoute(params: {
|
||||
@@ -355,7 +370,6 @@ function buildTargetRoute(params: {
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
approvalId: string;
|
||||
approvalKind?: ApprovalKind;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): Extract<SignalApprovalReactionRoute, { deliveryMode: "target" }> | null {
|
||||
@@ -379,7 +393,7 @@ function buildTargetRoute(params: {
|
||||
return isSignalApprovalReactionRouteStillEnabled({
|
||||
cfg: params.cfg,
|
||||
target: {
|
||||
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(params.approvalId),
|
||||
approvalKind: resolveApprovalKindFromId(params.approvalId),
|
||||
route,
|
||||
},
|
||||
})
|
||||
@@ -387,6 +401,64 @@ function buildTargetRoute(params: {
|
||||
: null;
|
||||
}
|
||||
|
||||
export function shouldAppendSignalApprovalReactionHintForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): boolean {
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: binding.approvalId,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function appendSignalApprovalReactionHintForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): string {
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (
|
||||
!binding ||
|
||||
!shouldAppendSignalApprovalReactionHintForOutboundMessage({
|
||||
...params,
|
||||
text: params.text,
|
||||
})
|
||||
) {
|
||||
return params.text;
|
||||
}
|
||||
return addSignalApprovalReactionHintToText({
|
||||
text: params.text,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasSignalApprovalReactionApprovers(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
@@ -399,7 +471,6 @@ export function registerSignalApprovalReactionTarget(params: {
|
||||
conversationKey: string;
|
||||
messageId: string;
|
||||
approvalId: string;
|
||||
approvalKind?: ApprovalKind;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
targetAuthorKeys: readonly string[];
|
||||
route: SignalApprovalReactionRoute;
|
||||
@@ -450,7 +521,7 @@ export function registerSignalApprovalReactionTarget(params: {
|
||||
} satisfies SignalApprovalReactionRoute);
|
||||
const target: SignalApprovalReactionTarget = {
|
||||
approvalId,
|
||||
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(approvalId),
|
||||
approvalKind: resolveApprovalKindFromId(approvalId),
|
||||
allowedDecisions,
|
||||
targetAuthorKeys,
|
||||
route,
|
||||
@@ -459,142 +530,50 @@ export function registerSignalApprovalReactionTarget(params: {
|
||||
return target;
|
||||
}
|
||||
|
||||
export function addSignalApprovalReactionHintToStructuredPayload(params: {
|
||||
export function registerSignalApprovalReactionTargetForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
accountId: string;
|
||||
to: string;
|
||||
payload: ReplyPayload;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
}): ReplyPayload | null {
|
||||
const metadata = getExecApprovalReplyMetadata(params.payload);
|
||||
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
|
||||
return null;
|
||||
}
|
||||
const route = buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
agentId: metadata.agentId,
|
||||
sessionKey: metadata.sessionKey,
|
||||
});
|
||||
if (!route || !params.payload.text) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...params.payload,
|
||||
text: addSignalApprovalReactionHintToText({
|
||||
text: params.payload.text,
|
||||
allowedDecisions: metadata.allowedDecisions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function readSignalDeliveryVisibleText(result: SignalApprovalDeliveryResult): string | null {
|
||||
const meta = result.meta;
|
||||
const visibleText = meta?.signalVisibleText ?? meta?.visibleText;
|
||||
return typeof visibleText === "string" ? visibleText : null;
|
||||
}
|
||||
|
||||
function listDeliveredSignalMessageIdsWithVisibleHint(params: {
|
||||
payload: ReplyPayload;
|
||||
results: readonly SignalApprovalDeliveryResult[];
|
||||
}): string[] {
|
||||
const signalResults = params.results.filter(
|
||||
(result) => !result.channel || normalizeLowercaseStringOrEmpty(result.channel) === "signal",
|
||||
);
|
||||
const resultsWithVisibleText = signalResults.filter(
|
||||
(result) => readSignalDeliveryVisibleText(result) !== null,
|
||||
);
|
||||
const candidates = resultsWithVisibleText.length > 0 ? resultsWithVisibleText : signalResults;
|
||||
if (resultsWithVisibleText.length === 0 && candidates.length !== 1) {
|
||||
return [];
|
||||
}
|
||||
const ids = candidates
|
||||
.filter((result) =>
|
||||
resultsWithVisibleText.length > 0
|
||||
? hasSignalApprovalReactionHintText(readSignalDeliveryVisibleText(result))
|
||||
: hasSignalApprovalReactionHintText(params.payload.text),
|
||||
)
|
||||
.map((result) => normalizeOptionalString(result.messageId))
|
||||
.filter((messageId): messageId is string => Boolean(messageId && messageId !== "unknown"));
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
export function registerSignalApprovalReactionTargetForDeliveredPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
target: SignalApprovalDeliveryTarget;
|
||||
payload: ReplyPayload;
|
||||
results: readonly SignalApprovalDeliveryResult[];
|
||||
messageId: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
ttlMs?: number;
|
||||
}): boolean {
|
||||
if (normalizeLowercaseStringOrEmpty(params.target.channel) !== "signal") {
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
const metadata = getExecApprovalReplyMetadata(params.payload);
|
||||
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hasSignalApprovalReactionHintText(params.payload.text)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.target.accountId })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const conversationKey = resolveSignalApprovalConversationKey(params.target.to);
|
||||
const conversationKey = resolveSignalApprovalConversationKey(params.to);
|
||||
if (!conversationKey) {
|
||||
return false;
|
||||
}
|
||||
const route = buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.target.accountId,
|
||||
to: params.target.to,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
agentId: metadata.agentId,
|
||||
sessionKey: metadata.sessionKey,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: binding.approvalId,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params);
|
||||
if (targetAuthorKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let registered = false;
|
||||
for (const messageId of listDeliveredSignalMessageIdsWithVisibleHint({
|
||||
payload: params.payload,
|
||||
results: params.results,
|
||||
})) {
|
||||
registered =
|
||||
Boolean(
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: normalizeAccountId(params.target.accountId ?? undefined),
|
||||
conversationKey,
|
||||
messageId,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
allowedDecisions: metadata.allowedDecisions,
|
||||
targetAuthorKeys,
|
||||
route,
|
||||
routeAllowed: true,
|
||||
ttlMs: params.ttlMs,
|
||||
}),
|
||||
) || registered;
|
||||
}
|
||||
return registered;
|
||||
return Boolean(
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: params.accountId,
|
||||
conversationKey,
|
||||
messageId: params.messageId,
|
||||
approvalId: binding.approvalId,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
targetAuthorKeys: resolveSignalApprovalTargetAuthorKeys(params),
|
||||
route,
|
||||
routeAllowed: true,
|
||||
ttlMs: params.ttlMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function unregisterSignalApprovalReactionTarget(params: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Signal plugin module implements channel behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound";
|
||||
@@ -41,12 +40,10 @@ import {
|
||||
} from "./shared.js";
|
||||
type SignalSendFn = typeof import("./send.runtime.js").sendMessageSignal;
|
||||
type SignalProbe = import("./probe.js").SignalProbe;
|
||||
type SignalApprovalReactionsModule = typeof import("./approval-reactions.js");
|
||||
|
||||
let signalMonitorModulePromise: Promise<typeof import("./monitor.js")> | null = null;
|
||||
let signalProbeModulePromise: Promise<typeof import("./probe.js")> | null = null;
|
||||
let signalSendRuntimePromise: Promise<typeof import("./send.runtime.js")> | null = null;
|
||||
let signalApprovalReactionsModulePromise: Promise<SignalApprovalReactionsModule> | null = null;
|
||||
|
||||
async function loadSignalMonitorModule() {
|
||||
signalMonitorModulePromise ??= import("./monitor.js");
|
||||
@@ -63,11 +60,6 @@ async function loadSignalSendRuntime() {
|
||||
return await signalSendRuntimePromise;
|
||||
}
|
||||
|
||||
async function loadSignalApprovalReactionsModule() {
|
||||
signalApprovalReactionsModulePromise ??= import("./approval-reactions.js");
|
||||
return await signalApprovalReactionsModulePromise;
|
||||
}
|
||||
|
||||
async function resolveSignalSendContext(params: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
accountId?: string;
|
||||
@@ -110,20 +102,6 @@ type SignalMessageContextExtras = {
|
||||
deps?: { [channelId: string]: unknown };
|
||||
};
|
||||
|
||||
function attachSignalVisibleText<T extends object>(result: T, visibleText: string) {
|
||||
const meta =
|
||||
"meta" in result && result.meta && typeof result.meta === "object"
|
||||
? (result.meta as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...result,
|
||||
meta: {
|
||||
...meta,
|
||||
signalVisibleText: visibleText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const signalMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "signal",
|
||||
durableFinal: {
|
||||
@@ -246,7 +224,7 @@ async function sendFormattedSignalText(ctx: {
|
||||
textMode: "plain",
|
||||
textStyles: chunk.styles,
|
||||
});
|
||||
results.push(attachSignalVisibleText(result, chunk.text));
|
||||
results.push(result);
|
||||
}
|
||||
return attachChannelToResults("signal", results);
|
||||
}
|
||||
@@ -289,49 +267,7 @@ async function sendFormattedSignalMedia(ctx: {
|
||||
textMode: "plain",
|
||||
textStyles: formatted.styles,
|
||||
});
|
||||
return attachChannelToResult("signal", attachSignalVisibleText(result, formatted.text));
|
||||
}
|
||||
|
||||
async function registerDeliveredSignalApprovalPayloadForReactions(
|
||||
params: Parameters<NonNullable<ChannelOutboundAdapter["afterDeliverPayload"]>>[0],
|
||||
) {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.target.accountId ?? undefined,
|
||||
});
|
||||
if (!account.config.account) {
|
||||
return;
|
||||
}
|
||||
const { registerSignalApprovalReactionTargetForDeliveredPayload } =
|
||||
await loadSignalApprovalReactionsModule();
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: params.cfg,
|
||||
target: params.target,
|
||||
payload: params.payload,
|
||||
results: params.results,
|
||||
targetAuthor: account.config.account,
|
||||
});
|
||||
}
|
||||
|
||||
async function renderSignalApprovalPayloadForReactions(
|
||||
params: Parameters<NonNullable<ChannelOutboundAdapter["renderPresentation"]>>[0],
|
||||
) {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.ctx.cfg,
|
||||
accountId: params.ctx.accountId ?? undefined,
|
||||
});
|
||||
if (!account.config.account) {
|
||||
return null;
|
||||
}
|
||||
const { addSignalApprovalReactionHintToStructuredPayload } =
|
||||
await loadSignalApprovalReactionsModule();
|
||||
return addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg: params.ctx.cfg,
|
||||
accountId: params.ctx.accountId ?? undefined,
|
||||
to: params.ctx.to,
|
||||
payload: params.payload,
|
||||
targetAuthor: account.config.account,
|
||||
});
|
||||
return attachChannelToResult("signal", result);
|
||||
}
|
||||
|
||||
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
@@ -468,9 +404,6 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
payload,
|
||||
hint,
|
||||
}),
|
||||
afterDeliverPayload: async (params) =>
|
||||
await registerDeliveredSignalApprovalPayloadForReactions(params),
|
||||
renderPresentation: async (params) => await renderSignalApprovalPayloadForReactions(params),
|
||||
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) =>
|
||||
await sendFormattedSignalText({
|
||||
cfg,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
// Signal tests cover core plugin behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
@@ -7,10 +6,6 @@ import {
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalPlugin } from "./channel.js";
|
||||
import * as clientModule from "./client-adapter.js";
|
||||
import { classifySignalCliLogLine } from "./daemon.js";
|
||||
@@ -269,143 +264,6 @@ describe("signal outbound", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("registers structured approval payloads for reactions after delivery", async () => {
|
||||
clearSignalApprovalReactionTargetsForTest();
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: "+15550009999",
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-after-delivery",
|
||||
approvalSlug: "exec-aft",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const rendered = await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload,
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
});
|
||||
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
|
||||
await signalPlugin.outbound?.afterDeliverPayload?.({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: rendered!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000099",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000099",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-after-delivery",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders reaction hints only from structured approval payloads", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: "+15550009999",
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-rendered-approval",
|
||||
approvalSlug: "exec-ren",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
});
|
||||
const rendered = await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload,
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
});
|
||||
|
||||
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
expect(
|
||||
await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload: {
|
||||
text: [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-rendered-approval",
|
||||
"",
|
||||
"Reply with: /approve exec-rendered-approval allow-once|deny",
|
||||
].join("\n"),
|
||||
presentation: payload.presentation,
|
||||
},
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("declares message adapter durable text and media with receipt proofs", async () => {
|
||||
const send = vi.fn(async (_to: string, _text: string, opts: { mediaUrl?: string } = {}) => {
|
||||
const messageId = opts.mediaUrl ? "signal-media-1" : "signal-text-1";
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
|
||||
const sendMocks = vi.hoisted(() => ({
|
||||
sendMessageSignal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
|
||||
return {
|
||||
...actual,
|
||||
sendMessageSignal: sendMocks.sendMessageSignal,
|
||||
};
|
||||
});
|
||||
|
||||
const { deliverReplies } = await import("./monitor.js");
|
||||
|
||||
const botAccount = "+15550009999";
|
||||
const approver = "+15551230000";
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: botAccount,
|
||||
allowFrom: [approver],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: approver }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
async function deliverReplyPayload(payload: ReplyPayload) {
|
||||
await deliverReplies({
|
||||
cfg,
|
||||
replies: [payload],
|
||||
target: approver,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
account: botAccount,
|
||||
accountId: "default",
|
||||
runtime: { log: vi.fn() } as never,
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
textLimit: 4000,
|
||||
chunkMode: "length",
|
||||
});
|
||||
}
|
||||
|
||||
describe("Signal monitor approval reply delivery", () => {
|
||||
beforeEach(() => {
|
||||
clearSignalApprovalReactionTargetsForTest();
|
||||
sendMocks.sendMessageSignal.mockReset().mockResolvedValue({
|
||||
messageId: "1700000000200",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds reaction hints and registers structured approval replies delivered by the monitor", async () => {
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-monitor-structured",
|
||||
approvalSlug: "exec-mon",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf monitor",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
|
||||
await deliverReplyPayload(payload);
|
||||
|
||||
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
|
||||
expect(sentText).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: approver,
|
||||
messageId: "1700000000200",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: botAccount,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-monitor-structured",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: approver,
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not bind ordinary monitor replies that quote approval commands", async () => {
|
||||
const payload = {
|
||||
text: [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-monitor-quoted",
|
||||
"",
|
||||
"Reply with: /approve exec-monitor-quoted allow-once|deny",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
await deliverReplyPayload(payload);
|
||||
|
||||
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
|
||||
expect(sentText).not.toContain("React with:");
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: approver,
|
||||
messageId: "1700000000200",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: botAccount,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -39,10 +39,6 @@ import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js";
|
||||
import {
|
||||
addSignalApprovalReactionHintToStructuredPayload,
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalRpcRequest, signalCheck } from "./client-adapter.js";
|
||||
import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js";
|
||||
import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
|
||||
@@ -358,7 +354,7 @@ async function fetchAttachment(params: {
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
async function deliverReplies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
@@ -373,79 +369,32 @@ export async function deliverReplies(params: {
|
||||
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
|
||||
params;
|
||||
for (const payload of replies) {
|
||||
const deliveryResults: Array<{
|
||||
channel: "signal";
|
||||
messageId: string;
|
||||
meta: { signalVisibleText: string };
|
||||
}> = [];
|
||||
const deliveredPayload =
|
||||
addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
to: target,
|
||||
payload,
|
||||
targetAuthor: account,
|
||||
}) ?? payload;
|
||||
const reply = resolveSendableOutboundReplyParts(deliveredPayload);
|
||||
const recordDeliveryResult = (
|
||||
result: Awaited<ReturnType<typeof sendMessageSignal>>,
|
||||
visibleText: string,
|
||||
) => {
|
||||
const messageId =
|
||||
typeof result?.messageId === "string" && result.messageId.trim()
|
||||
? result.messageId.trim()
|
||||
: null;
|
||||
if (messageId) {
|
||||
deliveryResults.push({
|
||||
channel: "signal",
|
||||
messageId,
|
||||
meta: { signalVisibleText: visibleText },
|
||||
});
|
||||
}
|
||||
};
|
||||
const reply = resolveSendableOutboundReplyParts(payload);
|
||||
const delivered = await deliverTextOrMediaReply({
|
||||
payload: deliveredPayload,
|
||||
payload,
|
||||
text: reply.text,
|
||||
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
recordDeliveryResult(
|
||||
await sendMessageSignal(target, chunk, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
}),
|
||||
chunk,
|
||||
);
|
||||
await sendMessageSignal(target, chunk, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
const visibleText = caption ?? "";
|
||||
recordDeliveryResult(
|
||||
await sendMessageSignal(target, visibleText, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId,
|
||||
}),
|
||||
visibleText,
|
||||
);
|
||||
await sendMessageSignal(target, caption ?? "", {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (delivered !== "empty") {
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: params.cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: target,
|
||||
accountId,
|
||||
},
|
||||
payload: deliveredPayload,
|
||||
results: deliveryResults,
|
||||
targetAuthor: account,
|
||||
});
|
||||
runtime.log?.(`delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,73 +129,4 @@ describe("sendMessageSignal receipts", () => {
|
||||
expect(result.messageId).toBe("unknown");
|
||||
expect(result.receipt.platformMessageIds).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not add approval reactions to ordinary outbound approval-looking text", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567892 });
|
||||
const text = [
|
||||
"Here is the command you asked about:",
|
||||
"/approve exec-live-approval allow-once|deny",
|
||||
].join("\n");
|
||||
|
||||
await sendMessageSignal("+15551234567", text, {
|
||||
cfg: {
|
||||
...SIGNAL_TEST_CFG,
|
||||
channels: {
|
||||
signal: {
|
||||
...SIGNAL_TEST_CFG.channels.signal,
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(signalRpcRequestMock).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({ message: text }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add approval reactions to ordinary outbound text quoting a full prompt", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567893 });
|
||||
const text = [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-live-approval",
|
||||
"",
|
||||
"Reply with: /approve exec-live-approval allow-once|deny",
|
||||
].join("\n");
|
||||
|
||||
await sendMessageSignal("+15551234567", text, {
|
||||
cfg: {
|
||||
...SIGNAL_TEST_CFG,
|
||||
channels: {
|
||||
signal: {
|
||||
...SIGNAL_TEST_CFG.channels.signal,
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(signalRpcRequestMock).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({ message: text }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runt
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import {
|
||||
appendSignalApprovalReactionHintForOutboundMessage,
|
||||
registerSignalApprovalReactionTargetForOutboundMessage,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalRpcRequest } from "./client-adapter.js";
|
||||
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
|
||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||
@@ -180,7 +184,14 @@ export async function sendMessageSignal(
|
||||
});
|
||||
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
|
||||
const target = parseTarget(to);
|
||||
let message = text ?? "";
|
||||
const outboundText = appendSignalApprovalReactionHintForOutboundMessage({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
to,
|
||||
text: text ?? "",
|
||||
targetAuthor: account,
|
||||
});
|
||||
let message = outboundText;
|
||||
let messageFromPlaceholder = false;
|
||||
let textStyles: SignalTextStyleRange[] = [];
|
||||
const textMode = opts.textMode ?? "markdown";
|
||||
@@ -262,6 +273,14 @@ export async function sendMessageSignal(
|
||||
});
|
||||
const timestamp = result?.timestamp;
|
||||
const messageId = timestamp ? String(timestamp) : "unknown";
|
||||
registerSignalApprovalReactionTargetForOutboundMessage({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
to,
|
||||
messageId,
|
||||
text: outboundText,
|
||||
targetAuthor: account,
|
||||
});
|
||||
return {
|
||||
messageId,
|
||||
timestamp,
|
||||
|
||||
@@ -833,7 +833,6 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeTelegramTargetId,
|
||||
hint: "<chatId>",
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
},
|
||||
},
|
||||
resolver: {
|
||||
|
||||
@@ -807,16 +807,16 @@ describe("createTelegramDraftStream", () => {
|
||||
expectNthPreviewSend(api, 2, "foo bar baz qux");
|
||||
});
|
||||
|
||||
it("clamps a first oversized non-final preview on a UTF-16 boundary", async () => {
|
||||
it("clamps a first oversized non-final preview", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api, { maxChars: 10 });
|
||||
|
||||
stream.update("123456789😀tail");
|
||||
stream.update("1234567890ABCDEFGHIJ");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectNthPreviewSend(api, 1, "123456789");
|
||||
expect(stream.lastDeliveredText?.()).toBe("123456789");
|
||||
expectNthPreviewSend(api, 1, "1234567890");
|
||||
expect(stream.lastDeliveredText?.()).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("finalizes overflow that was hidden by a clamped non-final preview", async () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
takeMessageIdAfterStop,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
|
||||
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js";
|
||||
import {
|
||||
@@ -170,7 +169,7 @@ function findTelegramDraftChunkLength(
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
return sliceUtf16Safe(text, 0, best).length;
|
||||
return best;
|
||||
}
|
||||
|
||||
export function createTelegramDraftStream(params: {
|
||||
|
||||
@@ -418,7 +418,7 @@ type TestTelegramUpdate = {
|
||||
update_id: number;
|
||||
message: {
|
||||
text: string;
|
||||
chat: { id: number; type: "private" | "supergroup" };
|
||||
chat: { id: number; type: "supergroup" };
|
||||
message_thread_id?: number;
|
||||
is_topic_message?: boolean;
|
||||
};
|
||||
@@ -436,16 +436,6 @@ function topicUpdate(updateId: number, threadId: number, text: string): TestTele
|
||||
};
|
||||
}
|
||||
|
||||
function directUpdate(updateId: number, chatId: number, text: string): TestTelegramUpdate {
|
||||
return {
|
||||
update_id: updateId,
|
||||
message: {
|
||||
text,
|
||||
chat: { id: chatId, type: "private" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForAbortSignal(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
@@ -1805,93 +1795,6 @@ describe("TelegramPollingSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
name: "topic",
|
||||
conflict: topicUpdate(42, 10, "retryable session init conflict"),
|
||||
blocked: topicUpdate(43, 10, "same topic must wait behind retry backoff"),
|
||||
other: topicUpdate(44, 11, "other topic can continue"),
|
||||
conflictEvent: "topic10:conflict",
|
||||
blockedEvent: "topic10:overtook",
|
||||
otherEvent: "topic11",
|
||||
error: "reply session initialization conflicted for agent:main:telegram:group:-100:topic:10",
|
||||
},
|
||||
{
|
||||
name: "direct message",
|
||||
conflict: directUpdate(42, 100, "retryable session init conflict"),
|
||||
blocked: directUpdate(43, 100, "same DM must wait behind retry backoff"),
|
||||
other: directUpdate(44, 101, "other DM can continue"),
|
||||
conflictEvent: "dm100:conflict",
|
||||
blockedEvent: "dm100:overtook",
|
||||
otherEvent: "dm101",
|
||||
error: "reply session initialization conflicted for agent:main:telegram:direct:100",
|
||||
},
|
||||
]) {
|
||||
it(`backs off retryable reply session init conflicts for ${scenario.name} lanes`, async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
await withTempSpool(async (tempDir) => {
|
||||
const abort = new AbortController();
|
||||
const log = vi.fn();
|
||||
let attempts = 0;
|
||||
const events: string[] = [];
|
||||
await writeSpooledTestUpdates(tempDir, [
|
||||
scenario.conflict,
|
||||
scenario.blocked,
|
||||
scenario.other,
|
||||
]);
|
||||
|
||||
const { runPromise, stopWorker } = startIsolatedIngressSession({
|
||||
abort,
|
||||
spoolDir: tempDir,
|
||||
log,
|
||||
drainIntervalMs: 100,
|
||||
handleUpdate: async (update) => {
|
||||
if (update.update_id === scenario.conflict.update_id) {
|
||||
attempts += 1;
|
||||
events.push(`${scenario.conflictEvent}:${attempts}`);
|
||||
throw new Error(scenario.error);
|
||||
}
|
||||
if (update.update_id === scenario.blocked.update_id) {
|
||||
events.push(scenario.blockedEvent);
|
||||
return;
|
||||
}
|
||||
if (update.update_id === scenario.other.update_id) {
|
||||
events.push(scenario.otherEvent);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(attempts).toBe(1));
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(attempts).toBe(1);
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toEqual([`${scenario.conflictEvent}:1`, scenario.otherEvent]),
|
||||
);
|
||||
expect(await pendingUpdateIds(tempDir, "all")).toEqual([
|
||||
scenario.conflict.update_id,
|
||||
scenario.blocked.update_id,
|
||||
]);
|
||||
expect(await failedUpdateIds(tempDir)).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_500);
|
||||
await vi.waitFor(() => expect(attempts).toBe(2));
|
||||
expect(events).not.toContain(scenario.blockedEvent);
|
||||
expectLogIncludes(
|
||||
log,
|
||||
`spooled update ${scenario.conflict.update_id} failed; keeping for retry`,
|
||||
);
|
||||
|
||||
abort.abort();
|
||||
stopWorker();
|
||||
await runPromise;
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("dead-letters wrapped missing harness failures", async () => {
|
||||
await withTempSpool(async (tempDir) => {
|
||||
const abort = new AbortController();
|
||||
|
||||
@@ -131,14 +131,11 @@ const TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS = 5_000;
|
||||
const TELEGRAM_SPOOLED_HANDLER_TIMEOUT_ENV = "OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS";
|
||||
const TELEGRAM_SPOOLED_DRAIN_START_LIMIT = 100;
|
||||
const TELEGRAM_SPOOLED_DRAIN_SCAN_LIMIT = TELEGRAM_SPOOLED_DRAIN_START_LIMIT * 10;
|
||||
const TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_BASE_MS = 5_000;
|
||||
const TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_MAX_MS = 60_000;
|
||||
const TELEGRAM_POLLING_CLIENT_TIMEOUT_FLOOR_SECONDS = Math.ceil(
|
||||
TELEGRAM_GET_UPDATES_REQUEST_TIMEOUT_MS / 1000,
|
||||
);
|
||||
const MISSING_AGENT_HARNESS_ERROR_NAME = "MissingAgentHarnessError";
|
||||
const MISSING_AGENT_HARNESS_MESSAGE_RE = /Requested agent harness "[^"]+" is not registered\./u;
|
||||
const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /reply session initialization conflicted for \S+/u;
|
||||
|
||||
function normalizeTelegramAccountId(accountId?: string | null): string {
|
||||
return accountId?.trim() || "default";
|
||||
@@ -172,24 +169,6 @@ function resolveNonRetryableSpooledUpdateFailure(
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveSpooledUpdateRetryDelayMs(update: TelegramSpooledUpdate, now = Date.now()): number {
|
||||
const attempts = update.attempts ?? 0;
|
||||
if (
|
||||
!update.lastError ||
|
||||
!REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(update.lastError) ||
|
||||
update.lastAttemptAt === undefined ||
|
||||
attempts <= 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const exponent = Math.min(attempts - 1, 8);
|
||||
const delayMs = Math.min(
|
||||
TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_MAX_MS,
|
||||
TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_BASE_MS * 2 ** exponent,
|
||||
);
|
||||
return Math.max(0, update.lastAttemptAt + delayMs - now);
|
||||
}
|
||||
|
||||
type TelegramBot = ReturnType<typeof createTelegramBot>;
|
||||
|
||||
const waitForGracefulStop = async (stop: () => Promise<void>) => {
|
||||
@@ -798,9 +777,7 @@ export class TelegramPollingSession {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await releaseTelegramSpooledUpdateClaim(params.update, {
|
||||
lastError: formatErrorMessage(params.err),
|
||||
});
|
||||
await releaseTelegramSpooledUpdateClaim(params.update);
|
||||
} catch (releaseErr) {
|
||||
this.opts.log(
|
||||
`[telegram][diag] spooled update ${params.update.updateId} failed and could not be requeued: ${formatErrorMessage(releaseErr)}`,
|
||||
@@ -888,10 +865,6 @@ export class TelegramPollingSession {
|
||||
if (this.opts.abortSignal?.aborted) {
|
||||
break;
|
||||
}
|
||||
if (resolveSpooledUpdateRetryDelayMs(update) > 0) {
|
||||
claimedLaneKeys.add(laneKey);
|
||||
continue;
|
||||
}
|
||||
const handlerKey = buildSpooledUpdateHandlerKey({ spoolDir: params.spoolDir, laneKey });
|
||||
if (activeSpooledUpdateHandlersByLane.has(handlerKey)) {
|
||||
blockedByLane.add(handlerKey);
|
||||
@@ -1560,7 +1533,6 @@ export const testing = {
|
||||
createTelegramRestartBackoffState,
|
||||
resetTelegramRestartBackoffState,
|
||||
resolveTelegramRestartDelayMs,
|
||||
resolveSpooledUpdateRetryDelayMs,
|
||||
resolveSpooledUpdateHandlerAbortGraceMs: (valueMs: unknown): number =>
|
||||
resolvePositiveTimerTimeoutMs(valueMs, TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS),
|
||||
};
|
||||
|
||||
@@ -38,9 +38,6 @@ export type TelegramSpooledUpdate = {
|
||||
path: string;
|
||||
update: unknown;
|
||||
receivedAt: number;
|
||||
attempts?: number;
|
||||
lastAttemptAt?: number;
|
||||
lastError?: string;
|
||||
claim?: TelegramSpooledUpdateClaimOwner;
|
||||
};
|
||||
|
||||
@@ -169,9 +166,6 @@ function parseQueueRecord(
|
||||
path: pendingPath(spoolDir, payload.updateId),
|
||||
update: payload.update,
|
||||
receivedAt: payload.receivedAt,
|
||||
attempts: record.attempts,
|
||||
...(record.lastAttemptAt === undefined ? {} : { lastAttemptAt: record.lastAttemptAt }),
|
||||
...(record.lastError === undefined ? {} : { lastError: record.lastError }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,11 +267,9 @@ export async function claimTelegramSpooledUpdate(
|
||||
|
||||
export async function releaseTelegramSpooledUpdateClaim(
|
||||
update: ClaimedTelegramSpooledUpdate,
|
||||
options?: { lastError?: string; releasedAt?: number },
|
||||
): Promise<void> {
|
||||
await createTelegramIngressQueue(path.dirname(update.pendingPath)).release(
|
||||
queueMutationTarget(update),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
// Voyage batch tests cover bounded status/error response reads.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { VoyageEmbeddingClient } from "./embedding-provider.js";
|
||||
import { testing } from "./embedding-batch.js";
|
||||
|
||||
const { fetchVoyageBatchStatus, readVoyageBatchError, VOYAGE_BATCH_RESPONSE_MAX_BYTES } = testing;
|
||||
|
||||
function buildClient(): VoyageEmbeddingClient {
|
||||
return {
|
||||
baseUrl: "https://api.voyageai.test/v1",
|
||||
headers: { authorization: "Bearer test" },
|
||||
model: "voyage-3",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build deps whose withRemoteHttpResponse drives the real onResponse against a
|
||||
* caller-provided Response, so the bounded readers run exactly as in production.
|
||||
*/
|
||||
function buildDeps(response: Response): Parameters<typeof fetchVoyageBatchStatus>[0]["deps"] {
|
||||
return {
|
||||
now: () => 0,
|
||||
sleep: async () => {},
|
||||
postJsonWithRetry: (async () => {
|
||||
throw new Error("postJsonWithRetry should not be called in these tests");
|
||||
}) as never,
|
||||
uploadBatchJsonlFile: (async () => {
|
||||
throw new Error("uploadBatchJsonlFile should not be called in these tests");
|
||||
}) as never,
|
||||
withRemoteHttpResponse: (async (params: { onResponse: (res: Response) => Promise<unknown> }) =>
|
||||
await params.onResponse(response)) as never,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A streaming JSON-ish body that proves an oversized response stops being read
|
||||
* before the whole advertised payload is buffered into memory. getReadCount
|
||||
* reports how many chunks were pulled; cancel() flips wasCanceled.
|
||||
*/
|
||||
function streamingResponse(params: { chunkCount: number; chunkSize: number; status?: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let reads = 0;
|
||||
let canceled = false;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (reads >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
reads += 1;
|
||||
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
status: params.status ?? 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
getReadCount: () => reads,
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
describe("voyage batch bounded reads", () => {
|
||||
it("uses a 16 MiB cap for batch status/error responses", () => {
|
||||
expect(VOYAGE_BATCH_RESPONSE_MAX_BYTES).toBe(16 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("parses a well-formed batch status response under the byte cap", async () => {
|
||||
const response = new Response(JSON.stringify({ id: "batch_1", status: "completed" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const status = await fetchVoyageBatchStatus({
|
||||
client: buildClient(),
|
||||
batchId: "batch_1",
|
||||
deps: buildDeps(response),
|
||||
});
|
||||
|
||||
expect(status).toEqual({ id: "batch_1", status: "completed" });
|
||||
});
|
||||
|
||||
it("caps an oversized batch status stream instead of buffering the whole body", async () => {
|
||||
const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024 });
|
||||
|
||||
await expect(
|
||||
fetchVoyageBatchStatus({
|
||||
client: buildClient(),
|
||||
batchId: "batch_1",
|
||||
deps: buildDeps(streamed.response),
|
||||
maxResponseBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow(/voyage-batch-status: JSON response exceeds 4096 bytes/);
|
||||
|
||||
// Stream was cancelled mid-flight: fewer chunks read than the full payload.
|
||||
expect(streamed.getReadCount()).toBeLessThan(64);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the full NDJSON parse chain for an under-cap error file", async () => {
|
||||
// Multi-line NDJSON with a blank line proves the bounded read does not
|
||||
// disturb the original trim/split("\n")/JSON.parse/extractBatchErrorMessage
|
||||
// pipeline: the first useful error message is still extracted byte-for-byte
|
||||
// identically to the pre-change `await res.text()` path.
|
||||
const body = [
|
||||
JSON.stringify({ custom_id: "req-0", response: { status_code: 200 } }),
|
||||
"",
|
||||
JSON.stringify({ custom_id: "req-1", error: { message: "voyage upstream rejected" } }),
|
||||
JSON.stringify({ custom_id: "req-2", error: { message: "second error ignored" } }),
|
||||
"",
|
||||
].join("\n");
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/x-ndjson" },
|
||||
});
|
||||
|
||||
const message = await readVoyageBatchError({
|
||||
client: buildClient(),
|
||||
errorFileId: "file_1",
|
||||
deps: buildDeps(response),
|
||||
});
|
||||
|
||||
// extractBatchErrorMessage returns the first line carrying a message, so the
|
||||
// success line is skipped and the second error is not surfaced.
|
||||
expect(message).toBe("voyage upstream rejected");
|
||||
});
|
||||
|
||||
it("returns undefined for an empty error file via the original empty-body branch", async () => {
|
||||
// Whitespace-only body must still hit the `!text.trim()` short-circuit after
|
||||
// decoding the bounded buffer, returning undefined exactly as before.
|
||||
const response = new Response(" \n", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/x-ndjson" },
|
||||
});
|
||||
|
||||
const message = await readVoyageBatchError({
|
||||
client: buildClient(),
|
||||
errorFileId: "file_1",
|
||||
deps: buildDeps(response),
|
||||
});
|
||||
|
||||
expect(message).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fail-softs an oversized error file into formatUnavailableBatchError by design", async () => {
|
||||
const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024 });
|
||||
|
||||
// Intended behavior: an over-cap error file must NOT throw out of
|
||||
// readVoyageBatchError. An unbounded error body would otherwise OOM the
|
||||
// worker, so the bounded overflow error is caught and degraded into a
|
||||
// diagnostic string via formatUnavailableBatchError. We accept the lost
|
||||
// detail; the overflow message names the cap so the truncation is visible.
|
||||
const readError = async () =>
|
||||
await readVoyageBatchError({
|
||||
client: buildClient(),
|
||||
errorFileId: "file_1",
|
||||
deps: buildDeps(streamed.response),
|
||||
maxResponseBytes: 4096,
|
||||
});
|
||||
|
||||
await expect(readError()).resolves.toMatch(
|
||||
/error file unavailable: voyage batch error file content exceeds 4096 bytes/,
|
||||
);
|
||||
|
||||
// The bounded reader still cancels the stream mid-flight rather than
|
||||
// buffering the whole advertised payload before failing soft.
|
||||
expect(streamed.getReadCount()).toBeLessThan(64);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
});
|
||||
|
||||
it("caps an oversized non-OK (error) diagnostic body instead of buffering it whole", async () => {
|
||||
// Regression for the non-OK gap: `assertVoyageResponseOk` previously read the
|
||||
// 4xx/5xx diagnostic body with an unbounded `await res.text()`. A hostile
|
||||
// endpoint can return a 500 with a never-ending body, so that read must be
|
||||
// bounded too. Drive a streaming 500 through the real status path and assert
|
||||
// the bounded overflow error fires and the stream is cancelled mid-flight.
|
||||
const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024, status: 500 });
|
||||
|
||||
await expect(
|
||||
fetchVoyageBatchStatus({
|
||||
client: buildClient(),
|
||||
batchId: "batch_1",
|
||||
deps: buildDeps(streamed.response),
|
||||
maxResponseBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow(/voyage batch status failed: 500 \(error body exceeds 4096 bytes\)/);
|
||||
|
||||
// Stream was cancelled mid-flight rather than draining the whole body.
|
||||
expect(streamed.getReadCount()).toBeLessThan(64);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the diagnostic shape for a small non-OK (error) body", async () => {
|
||||
// Under-cap non-OK body must still surface the original
|
||||
// `${context}: ${status} ${text}` diagnostic byte-for-byte.
|
||||
const response = new Response("voyage upstream is down", {
|
||||
status: 503,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchVoyageBatchStatus({
|
||||
client: buildClient(),
|
||||
batchId: "batch_1",
|
||||
deps: buildDeps(response),
|
||||
}),
|
||||
).rejects.toThrow(/voyage batch status failed: 503 voyage upstream is down/);
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
uploadBatchJsonlFile,
|
||||
withRemoteHttpResponse,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { VoyageEmbeddingClient } from "./embedding-provider.js";
|
||||
|
||||
@@ -43,10 +41,6 @@ type VoyageBatchOutputLine = ProviderBatchOutputLine;
|
||||
const VOYAGE_BATCH_ENDPOINT = EMBEDDING_BATCH_ENDPOINT;
|
||||
const VOYAGE_BATCH_COMPLETION_WINDOW = "12h";
|
||||
const VOYAGE_BATCH_MAX_REQUESTS = 50000;
|
||||
// Voyage batch status/error responses are untrusted external bodies. Cap them
|
||||
// the same way other bundled providers do (16 MiB) so a misbehaving or hostile
|
||||
// endpoint cannot stream an unbounded body into memory before we parse it.
|
||||
const VOYAGE_BATCH_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
type VoyageBatchDeps = {
|
||||
now: () => number;
|
||||
@@ -71,23 +65,9 @@ function resolveVoyageBatchDeps(overrides: Partial<VoyageBatchDeps> | undefined)
|
||||
};
|
||||
}
|
||||
|
||||
async function assertVoyageResponseOk(
|
||||
res: Response,
|
||||
context: string,
|
||||
maxBytes: number = VOYAGE_BATCH_RESPONSE_MAX_BYTES,
|
||||
): Promise<void> {
|
||||
async function assertVoyageResponseOk(res: Response, context: string): Promise<void> {
|
||||
if (!res.ok) {
|
||||
// The non-OK diagnostic body is just as untrusted as the success body: a
|
||||
// misbehaving or hostile endpoint can return a 4xx/5xx with an unbounded
|
||||
// body, and the old `await res.text()` buffered it whole before we threw.
|
||||
// Read it through the same bounded reader (16 MiB cap, stream cancelled on
|
||||
// overflow) while preserving the original `${context}: ${status} ${text}`
|
||||
// diagnostic shape for backward compatibility.
|
||||
const bytes = await readResponseWithLimit(res, maxBytes, {
|
||||
onOverflow: ({ maxBytes: maxBytesLocal }) =>
|
||||
new Error(`${context}: ${res.status} (error body exceeds ${maxBytesLocal} bytes)`),
|
||||
});
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
const text = await res.text();
|
||||
throw new Error(`${context}: ${res.status} ${text}`);
|
||||
}
|
||||
}
|
||||
@@ -147,18 +127,14 @@ async function fetchVoyageBatchStatus(params: {
|
||||
client: VoyageEmbeddingClient;
|
||||
batchId: string;
|
||||
deps: VoyageBatchDeps;
|
||||
maxResponseBytes?: number;
|
||||
}): Promise<VoyageBatchStatus> {
|
||||
const maxBytes = params.maxResponseBytes ?? VOYAGE_BATCH_RESPONSE_MAX_BYTES;
|
||||
return await params.deps.withRemoteHttpResponse(
|
||||
buildVoyageBatchRequest({
|
||||
client: params.client,
|
||||
path: `batches/${params.batchId}`,
|
||||
onResponse: async (res) => {
|
||||
await assertVoyageResponseOk(res, "voyage batch status failed", maxBytes);
|
||||
return await readProviderJsonResponse<VoyageBatchStatus>(res, "voyage-batch-status", {
|
||||
maxBytes,
|
||||
});
|
||||
await assertVoyageResponseOk(res, "voyage batch status failed");
|
||||
return (await res.json()) as VoyageBatchStatus;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -168,21 +144,15 @@ async function readVoyageBatchError(params: {
|
||||
client: VoyageEmbeddingClient;
|
||||
errorFileId: string;
|
||||
deps: VoyageBatchDeps;
|
||||
maxResponseBytes?: number;
|
||||
}): Promise<string | undefined> {
|
||||
const maxBytes = params.maxResponseBytes ?? VOYAGE_BATCH_RESPONSE_MAX_BYTES;
|
||||
try {
|
||||
return await params.deps.withRemoteHttpResponse(
|
||||
buildVoyageBatchRequest({
|
||||
client: params.client,
|
||||
path: `files/${params.errorFileId}/content`,
|
||||
onResponse: async (res) => {
|
||||
await assertVoyageResponseOk(res, "voyage batch error file content failed", maxBytes);
|
||||
const bytes = await readResponseWithLimit(res, maxBytes, {
|
||||
onOverflow: ({ maxBytes: maxBytesLocal }) =>
|
||||
new Error(`voyage batch error file content exceeds ${maxBytesLocal} bytes`),
|
||||
});
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
await assertVoyageResponseOk(res, "voyage batch error file content failed");
|
||||
const text = await res.text();
|
||||
if (!text.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -310,9 +280,10 @@ export async function runVoyageEmbeddingBatches(
|
||||
headers: buildBatchHeaders(params.client, { json: true }),
|
||||
},
|
||||
onResponse: async (contentRes) => {
|
||||
// Same bounded non-OK diagnostic read as the status/error-file paths:
|
||||
// the failure body is untrusted, so cap it instead of `await text()`.
|
||||
await assertVoyageResponseOk(contentRes, "voyage batch file content failed");
|
||||
if (!contentRes.ok) {
|
||||
const text = await contentRes.text();
|
||||
throw new Error(`voyage batch file content failed: ${contentRes.status} ${text}`);
|
||||
}
|
||||
|
||||
if (!contentRes.body) {
|
||||
return;
|
||||
@@ -345,9 +316,3 @@ export async function runVoyageEmbeddingBatches(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
fetchVoyageBatchStatus,
|
||||
readVoyageBatchError,
|
||||
VOYAGE_BATCH_RESPONSE_MAX_BYTES,
|
||||
} as const;
|
||||
|
||||
@@ -8,9 +8,8 @@ import {
|
||||
assertOkOrThrowHttpError,
|
||||
buildAudioTranscriptionFormData,
|
||||
postTranscriptionRequest,
|
||||
readProviderJsonResponse,
|
||||
requireTranscriptionText,
|
||||
resolveProviderHttpRequestConfig,
|
||||
requireTranscriptionText,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { XAI_BASE_URL } from "./model-definitions.js";
|
||||
@@ -69,7 +68,7 @@ export async function transcribeXaiAudio(
|
||||
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "xAI audio transcription failed");
|
||||
const payload = await readProviderJsonResponse<XaiSttResponse>(response, "xai.stt");
|
||||
const payload = (await response.json()) as XaiSttResponse;
|
||||
return {
|
||||
text: requireTranscriptionText(payload.text, "xAI transcription response missing text"),
|
||||
...(model ? { model } : {}),
|
||||
|
||||
@@ -16,18 +16,6 @@ if [[ ! -f "$FILTER_FILES" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)"
|
||||
if [[ -n "$GIT_DIR" ]] && \
|
||||
{ [[ -f "$GIT_DIR/MERGE_HEAD" ]] || \
|
||||
[[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]] || \
|
||||
[[ -f "$GIT_DIR/REVERT_HEAD" ]] || \
|
||||
[[ -f "$GIT_DIR/REBASE_HEAD" ]] || \
|
||||
[[ -d "$GIT_DIR/rebase-merge" ]] || \
|
||||
[[ -d "$GIT_DIR/rebase-apply" ]]; }; then
|
||||
# Sequencer commits stage the operation result, not just the user's local edits.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Security: avoid option-injection from malicious file names (e.g. "--all", "--force").
|
||||
# Robustness: NUL-delimited file list handles spaces/newlines safely.
|
||||
# Compatibility: use read loops instead of `mapfile` so this runs on macOS Bash 3.x.
|
||||
|
||||
@@ -34,19 +34,6 @@ describe("acp session manager", () => {
|
||||
expect(store.getSessionByRunId("run-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes stale run lookup entries when rebinding an active run", () => {
|
||||
const session = store.createSession({
|
||||
sessionKey: "acp:rebind",
|
||||
cwd: "/tmp",
|
||||
});
|
||||
|
||||
store.setActiveRun(session.sessionId, "run-old", new AbortController());
|
||||
store.setActiveRun(session.sessionId, "run-new", new AbortController());
|
||||
|
||||
expect(store.getSessionByRunId("run-old")).toBeUndefined();
|
||||
expect(store.getSessionByRunId("run-new")?.sessionId).toBe(session.sessionId);
|
||||
});
|
||||
|
||||
it("deletes sessions and aborts active runs on close", () => {
|
||||
const session = store.createSession({
|
||||
sessionId: "close-me",
|
||||
|
||||
@@ -150,9 +150,6 @@ export function createInMemorySessionStore(options: AcpSessionStoreOptions = {})
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
if (session.activeRunId && session.activeRunId !== runId) {
|
||||
runIdToSessionId.delete(session.activeRunId);
|
||||
}
|
||||
session.activeRunId = runId;
|
||||
session.abortController = abortController;
|
||||
runIdToSessionId.set(runId, sessionId);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// Agent Core tests cover prompt template argument parsing behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js";
|
||||
|
||||
describe("prompt template arguments", () => {
|
||||
it("preserves quoted empty arguments so positional placeholders stay aligned", () => {
|
||||
expect(parseCommandArgs('first "" third')).toEqual(["first", "", "third"]);
|
||||
expect(parseCommandArgs("first '' third")).toEqual(["first", "", "third"]);
|
||||
expect(substituteArgs("$1|$2|$3", parseCommandArgs('first "" third'))).toBe("first||third");
|
||||
});
|
||||
});
|
||||
@@ -5,31 +5,26 @@ export function parseCommandArgs(argsString: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = "";
|
||||
let inQuote: string | null = null;
|
||||
let hasToken = false;
|
||||
|
||||
for (const char of argsString) {
|
||||
if (inQuote) {
|
||||
if (char === inQuote) {
|
||||
inQuote = null;
|
||||
} else {
|
||||
hasToken = true;
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"' || char === "'") {
|
||||
hasToken = true;
|
||||
inQuote = char;
|
||||
} else if (/\s/.test(char)) {
|
||||
if (hasToken) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = "";
|
||||
hasToken = false;
|
||||
}
|
||||
} else {
|
||||
hasToken = true;
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (hasToken) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
}
|
||||
return args;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
|
||||
@@ -55,27 +55,4 @@ describe("media-generation catalog", () => {
|
||||
}),
|
||||
).toEqual(["video-default", "video-pro"]);
|
||||
});
|
||||
|
||||
it("marks a trimmed default model as the catalog default", () => {
|
||||
expect(
|
||||
synthesizeMediaGenerationCatalogEntries({
|
||||
kind: "video_generation",
|
||||
provider: {
|
||||
id: "example",
|
||||
defaultModel: " video-default ",
|
||||
models: ["video-default"],
|
||||
capabilities: {},
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "video_generation",
|
||||
provider: "example",
|
||||
model: "video-default",
|
||||
source: "static",
|
||||
default: true,
|
||||
capabilities: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,6 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: {
|
||||
provider: MediaGenerationCatalogProvider<TCapabilities>;
|
||||
modes?: readonly string[];
|
||||
}): Array<MediaGenerationCatalogEntry<TCapabilities>> {
|
||||
const defaultModel = uniqueTrimmedStrings([params.provider.defaultModel])[0];
|
||||
return uniqueModels(params.provider).map((model) => {
|
||||
const entry: MediaGenerationCatalogEntry<TCapabilities> = {
|
||||
kind: params.kind,
|
||||
@@ -63,7 +62,7 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: {
|
||||
if (params.provider.label) {
|
||||
entry.label = params.provider.label;
|
||||
}
|
||||
if (model === defaultModel) {
|
||||
if (model === params.provider.defaultModel) {
|
||||
entry.default = true;
|
||||
}
|
||||
if (params.modes) {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// Media Understanding Common tests cover provider output extraction behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractGeminiResponse } from "./output-extract.js";
|
||||
|
||||
describe("extractGeminiResponse", () => {
|
||||
it("extracts the response from noisy output with nested JSON objects", () => {
|
||||
expect(
|
||||
extractGeminiResponse(
|
||||
[
|
||||
"debug: invoking gemini",
|
||||
JSON.stringify({
|
||||
response: "a useful description",
|
||||
usage: {
|
||||
inputTokens: 12,
|
||||
outputTokens: 4,
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
),
|
||||
).toBe("a useful description");
|
||||
});
|
||||
|
||||
it("returns null for an incomplete JSON object", () => {
|
||||
expect(extractGeminiResponse("{")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores unmatched quotes in noisy output before the JSON object", () => {
|
||||
expect(extractGeminiResponse('debug: model said "hello\n{"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("ignores braces inside quoted noisy output", () => {
|
||||
expect(extractGeminiResponse('debug: "hello { world" {"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("ignores shell-quoted JSON-like noisy output", () => {
|
||||
expect(extractGeminiResponse('debug: \'{"response":"fake"}\'')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat apostrophes inside noisy words as quote delimiters", () => {
|
||||
expect(extractGeminiResponse('debug: it\'s done {"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("resynchronizes after an unmatched brace in noisy output", () => {
|
||||
expect(extractGeminiResponse('debug: generated {\n{"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("preserves brace-heavy response text", () => {
|
||||
const response = "{".repeat(33);
|
||||
expect(extractGeminiResponse(JSON.stringify({ response }))).toBe(response);
|
||||
});
|
||||
|
||||
it("extracts pretty-printed JSON output", () => {
|
||||
expect(
|
||||
extractGeminiResponse(
|
||||
JSON.stringify(
|
||||
{
|
||||
response: "pretty response",
|
||||
usage: { inputTokens: 12 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
).toBe("pretty response");
|
||||
});
|
||||
|
||||
it("preserves pretty-printed object elements inside arrays", () => {
|
||||
expect(
|
||||
extractGeminiResponse(
|
||||
JSON.stringify(
|
||||
{
|
||||
response: "array response",
|
||||
items: [{ id: 1 }, { id: 2 }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
).toBe("array response");
|
||||
});
|
||||
|
||||
it("does not accept an inner response from a malformed trailing object", () => {
|
||||
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"} broken}')).toBe(
|
||||
"good",
|
||||
);
|
||||
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"}')).toBe("good");
|
||||
});
|
||||
|
||||
it("ignores a nested response inside an unfinished outer object", () => {
|
||||
expect(extractGeminiResponse('noise {"meta":{"response":"bad"}')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not promote a child from a malformed outer object", () => {
|
||||
expect(extractGeminiResponse('{"response":"good"} {"meta" {"response":"bad"}}')).toBe("good");
|
||||
expect(extractGeminiResponse('noise {broken {"response":"bad"}}')).toBeNull();
|
||||
expect(extractGeminiResponse('{"response":"good"}\nnoise {broken\n{"response":"bad"}}')).toBe(
|
||||
"good",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,119 +3,16 @@
|
||||
/** Parse the last JSON object in a noisy provider output string. */
|
||||
function extractLastJsonObject(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
const ranges: Array<{ end: number; start: number }> = [];
|
||||
const starts: number[] = [];
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let preambleQuote: string | undefined;
|
||||
let preambleEscaped = false;
|
||||
let previousSignificant: string | undefined;
|
||||
let lineHasNonWhitespace = false;
|
||||
let arrayDepth = 0;
|
||||
let candidateHasContent = false;
|
||||
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
const character = trimmed[index];
|
||||
if (inString) {
|
||||
if (character === "\n" || character === "\r") {
|
||||
starts.length = 0;
|
||||
inString = false;
|
||||
escaped = false;
|
||||
} else if (escaped) {
|
||||
escaped = false;
|
||||
} else if (character === "\\") {
|
||||
escaped = true;
|
||||
} else if (character === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts.length === 0) {
|
||||
if (preambleQuote !== undefined) {
|
||||
if (character === "\n" || character === "\r") {
|
||||
preambleQuote = undefined;
|
||||
preambleEscaped = false;
|
||||
} else if (preambleEscaped) {
|
||||
preambleEscaped = false;
|
||||
} else if (character === "\\") {
|
||||
preambleEscaped = true;
|
||||
} else if (character === preambleQuote) {
|
||||
preambleQuote = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'" || character === "`") {
|
||||
const previous = trimmed[index - 1];
|
||||
if (previous === undefined || /[\s:([{]/.test(previous)) {
|
||||
preambleQuote = character;
|
||||
preambleEscaped = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (character === "{") {
|
||||
arrayDepth = 0;
|
||||
candidateHasContent = false;
|
||||
starts.push(index);
|
||||
}
|
||||
if (!/\s/.test(character)) {
|
||||
previousSignificant = character;
|
||||
lineHasNonWhitespace = true;
|
||||
} else if (character === "\n" || character === "\r") {
|
||||
lineHasNonWhitespace = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const hadCandidateContent = candidateHasContent;
|
||||
if (character === '"') {
|
||||
inString = true;
|
||||
} else if (character === "{") {
|
||||
if (
|
||||
previousSignificant === ":" ||
|
||||
previousSignificant === "[" ||
|
||||
previousSignificant === '"' ||
|
||||
(previousSignificant === "," && (lineHasNonWhitespace || arrayDepth > 0))
|
||||
) {
|
||||
starts.push(index);
|
||||
} else if (!lineHasNonWhitespace && !hadCandidateContent) {
|
||||
// Only resync at a clean record boundary; otherwise keep malformed
|
||||
// outer objects from promoting diagnostic payloads as valid results.
|
||||
starts.length = 1;
|
||||
starts[0] = index;
|
||||
arrayDepth = 0;
|
||||
candidateHasContent = false;
|
||||
}
|
||||
} else if (character === "}" && starts.length > 0) {
|
||||
const start = starts.pop();
|
||||
if (start !== undefined && starts.length === 0) {
|
||||
ranges.push({ start, end: index });
|
||||
}
|
||||
} else if (character === "[") {
|
||||
arrayDepth += 1;
|
||||
} else if (character === "]" && arrayDepth > 0) {
|
||||
arrayDepth -= 1;
|
||||
}
|
||||
|
||||
if (!/\s/.test(character)) {
|
||||
candidateHasContent = true;
|
||||
previousSignificant = character;
|
||||
lineHasNonWhitespace = true;
|
||||
} else if (character === "\n" || character === "\r") {
|
||||
lineHasNonWhitespace = false;
|
||||
}
|
||||
const start = trimmed.lastIndexOf("{");
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let index = ranges.length - 1; index >= 0; index -= 1) {
|
||||
const range = ranges[index];
|
||||
try {
|
||||
return JSON.parse(trimmed.slice(range.start, range.end + 1));
|
||||
} catch {
|
||||
// Ignore malformed objects and try the previous completed range.
|
||||
}
|
||||
const slice = trimmed.slice(start);
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract Gemini CLI-style response text from the last JSON object in output. */
|
||||
|
||||
@@ -108,7 +108,7 @@ flow:
|
||||
- lambda:
|
||||
params: [text]
|
||||
expr: "config.expectedReplyGroups.every((group) => group.some((needle) => normalizeLowercaseStringOrEmpty(text).includes(needle)))"
|
||||
- expr: "30000"
|
||||
- expr: "env.providerMode === 'mock-openai' ? 10000 : 30000"
|
||||
- expr: "env.providerMode === 'mock-openai' ? 100 : 250"
|
||||
- if:
|
||||
expr: "Boolean(env.mock)"
|
||||
@@ -240,11 +240,7 @@ flow:
|
||||
message:
|
||||
expr: "lastError instanceof Error ? formatErrorMessage(lastError) : String(lastError ?? 'fanout retry exhausted')"
|
||||
- if:
|
||||
# Codex completes child sessions through its app-server path but
|
||||
# does not relay the child marker back onto the parent QA channel.
|
||||
# The shared assertions above already prove both child tool calls
|
||||
# and child session rows; keep this transport-only proof OpenClaw-specific.
|
||||
expr: "Boolean(env.mock) && env.gateway.runtimeEnv.OPENCLAW_QA_FORCE_RUNTIME !== 'codex'"
|
||||
expr: "Boolean(env.mock)"
|
||||
then:
|
||||
- forEach:
|
||||
items:
|
||||
@@ -257,5 +253,5 @@ flow:
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "String(candidate.text ?? '').trim() === childCompletionMarker"
|
||||
- 30000
|
||||
- 10000
|
||||
detailsExpr: "details"
|
||||
|
||||
@@ -26,9 +26,7 @@ scenario:
|
||||
config:
|
||||
sessionKey: agent:qa:long-context-cache-stability
|
||||
fixtureFile: large-cache-fixture.txt
|
||||
cacheEvidenceNeedle: CACHE-FIXTURE-0050
|
||||
cacheEvidenceLine: "CACHE-FIXTURE-0050: stable tool-result evidence for prompt-cache reuse across long sessions."
|
||||
followupPromptNeedle: Using the already-read
|
||||
cacheEvidenceNeedle: CACHE-FIXTURE-0550
|
||||
warmupMarker: QA-LARGE-CACHE-WARMUP-OK
|
||||
hitMarker: QA-LARGE-CACHE-HIT-OK
|
||||
|
||||
@@ -86,17 +84,8 @@ flow:
|
||||
- set: debugRequests
|
||||
value:
|
||||
expr: "env.mock ? [...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))] : []"
|
||||
- set: cappedReadOutputIndex
|
||||
value:
|
||||
expr: "debugRequests.reduce((found, planned, index) => { if (found >= 0 || !planned.plannedToolCallId || planned.plannedToolName !== 'read' || planned.plannedToolArgs?.path !== config.fixtureFile) return found; const outputOffset = debugRequests.slice(index + 1).findIndex((candidate) => Boolean(candidate.toolOutputCallId) && candidate.toolOutputCallId === planned.plannedToolCallId); if (outputOffset < 0) return found; const output = debugRequests[index + 1 + outputOffset]; const evidence = [planned.allInputText, output.allInputText, output.toolOutput].filter((value) => typeof value === 'string').join('\\n'); const hasCodexFormattedTruncation = evidence.includes('Warning: truncated output') && (evidence.includes('chars truncated') || evidence.includes('tokens truncated')); return evidence.includes(config.cacheEvidenceLine) && (evidence.includes('[Read output capped at 50KB') || evidence.includes('...(OpenClaw truncated dynamic tool result') || evidence.includes('...(truncated)...') || hasCodexFormattedTruncation) ? index + 1 + outputOffset : found; }, -1)"
|
||||
- set: hasCappedReadEvidence
|
||||
value:
|
||||
expr: "cappedReadOutputIndex >= 0"
|
||||
- set: hasFollowupCacheEvidence
|
||||
value:
|
||||
expr: "cappedReadOutputIndex >= 0 && debugRequests.some((request, index) => index > cappedReadOutputIndex && String(request.prompt ?? '').includes(config.followupPromptNeedle) && String(request.allInputText ?? '').includes(config.cacheEvidenceLine))"
|
||||
- assert:
|
||||
expr: "!env.mock || (hasCappedReadEvidence && hasFollowupCacheEvidence)"
|
||||
expr: "!env.mock || debugRequests.some((request, index) => request.plannedToolName === 'read' && request.plannedToolArgs?.path === config.fixtureFile && typeof request.plannedToolCallId === 'string' && debugRequests.slice(index + 1).some((result, resultOffset) => result.toolOutputCallId === request.plannedToolCallId && String(result.toolOutput ?? '').includes(config.cacheEvidenceNeedle) && (String(result.toolOutput ?? '').includes('[Read output capped at 50KB') || (String(result.toolOutput ?? '').includes('...(truncated)...') && String(result.toolOutput ?? '').length <= 13000)) && debugRequests.slice(index + resultOffset + 2).some((followup) => followup.plannedToolName === 'read' && followup.plannedToolArgs?.path === config.fixtureFile && String(followup.allInputText ?? '').includes(config.cacheEvidenceNeedle) && (String(followup.allInputText ?? '').includes('[Read output capped at 50KB') || String(followup.allInputText ?? '').includes('...(truncated)...')))))"
|
||||
message:
|
||||
expr: "`large capped read cache evidence was not observed: ${JSON.stringify({ hasCappedReadEvidence, hasFollowupCacheEvidence, requests: debugRequests.slice(-8).map((request) => ({ prompt: request.prompt ?? null, plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, plannedToolCallId: request.plannedToolCallId ?? null, toolOutputCallId: request.toolOutputCallId ?? null, toolOutputLength: String(request.toolOutput ?? '').length, outputHasReadCap: String(request.toolOutput ?? '').includes('[Read output capped at 50KB'), outputHasCodexTruncation: String(request.toolOutput ?? '').includes('...(truncated)...'), inputHasEvidenceLine: String(request.allInputText ?? '').includes(config.cacheEvidenceLine) })) })}`"
|
||||
expr: "`large capped read tool result was not observed: ${JSON.stringify(debugRequests.slice(-8).map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, plannedToolCallId: request.plannedToolCallId ?? null, toolOutputCallId: request.toolOutputCallId ?? null, toolOutputLength: String(request.toolOutput ?? '').length, toolOutputHasNeedle: String(request.toolOutput ?? '').includes(config.cacheEvidenceNeedle), toolOutputHasReadCap: String(request.toolOutput ?? '').includes('[Read output capped at 50KB'), toolOutputHasCodexTruncation: String(request.toolOutput ?? '').includes('...(truncated)...'), inputHasNeedle: String(request.allInputText ?? '').includes(config.cacheEvidenceNeedle), inputHasReadCap: String(request.allInputText ?? '').includes('[Read output capped at 50KB'), inputHasCodexTruncation: String(request.allInputText ?? '').includes('...(truncated)...') })))}`"
|
||||
detailsExpr: "outbound?.text ?? config.hitMarker"
|
||||
|
||||
@@ -25,35 +25,10 @@ PROBE_ATTEMPT_TIMEOUT_MS="$(
|
||||
PROBE_MAX_BODY_BYTES="$(
|
||||
openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_PROBE_MAX_BODY_BYTES 1048576
|
||||
)"
|
||||
ROOT_MANAGED_VPS="${OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS:-0}"
|
||||
|
||||
resolve_lane_artifact_suffix() {
|
||||
if [ -n "${OPENCLAW_DOCKER_ALL_LANE_NAME:-}" ]; then
|
||||
printf "%s" "$OPENCLAW_DOCKER_ALL_LANE_NAME"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$ROOT_MANAGED_VPS" = "1" ]; then
|
||||
printf "root-managed-vps-upgrade"
|
||||
elif [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then
|
||||
printf "update-restart-auth"
|
||||
elif [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
|
||||
printf "published-upgrade-survivor"
|
||||
else
|
||||
printf "upgrade-survivor"
|
||||
fi
|
||||
|
||||
if [ -n "${BASELINE_SPEC// }" ]; then
|
||||
printf -- "-%s" "$BASELINE_SPEC"
|
||||
fi
|
||||
if [ "$SCENARIO" != "base" ]; then
|
||||
printf -- "-%s" "$SCENARIO"
|
||||
fi
|
||||
}
|
||||
|
||||
LANE_ARTIFACT_SUFFIX="$(resolve_lane_artifact_suffix)"
|
||||
LANE_ARTIFACT_SUFFIX="${OPENCLAW_DOCKER_ALL_LANE_NAME:-default}"
|
||||
LANE_ARTIFACT_SUFFIX="${LANE_ARTIFACT_SUFFIX//[^A-Za-z0-9_.-]/_}"
|
||||
ARTIFACT_DIR="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/upgrade-survivor/$LANE_ARTIFACT_SUFFIX}"
|
||||
ROOT_MANAGED_VPS="${OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS:-0}"
|
||||
DOCKER_RUN_USER_ARGS=()
|
||||
PROBE_ENV_ARGS=(
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS="$PROBE_TIMEOUT_MS"
|
||||
|
||||
@@ -21,9 +21,6 @@ export const pluginSdkDocMetadata = {
|
||||
health: {
|
||||
category: "core",
|
||||
},
|
||||
sandbox: {
|
||||
category: "runtime",
|
||||
},
|
||||
"approval-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
|
||||
@@ -203,14 +203,14 @@ try {
|
||||
budgets = {
|
||||
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10386),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5212),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5215),
|
||||
publicDeprecatedExports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
||||
3247,
|
||||
),
|
||||
publicWildcardReexports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
|
||||
214,
|
||||
215,
|
||||
),
|
||||
};
|
||||
publicDeprecatedExportsByEntrypointBudget = readEntrypointBudgetEnv(
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Exercises result coercion, error wrapping, client delegation, and conflict
|
||||
* detection at the ToolDefinition boundary.
|
||||
*/
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool } from "openclaw/plugin-sdk/agent-core";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
toToolDefinitions,
|
||||
} from "./agent-tool-definition-adapter.js";
|
||||
import { wrapToolWithBeforeToolCallHook } from "./agent-tools.before-tool-call.js";
|
||||
import { createExecTool } from "./bash-tools.exec.js";
|
||||
import type { ClientToolDefinition } from "./embedded-agent-runner/run/params.js";
|
||||
|
||||
type ToolExecute = ReturnType<typeof toToolDefinitions>[number]["execute"];
|
||||
@@ -96,154 +93,6 @@ describe("agent tool definition adapter", () => {
|
||||
expect(details?.error).toBe("nope");
|
||||
});
|
||||
|
||||
it("preserves exec deny before prepared workdir failures", async () => {
|
||||
const tool = createExecTool({
|
||||
security: "deny",
|
||||
ask: "off",
|
||||
});
|
||||
const [definition] = toToolDefinitions([tool]);
|
||||
const missingWorkdir = path.join(os.tmpdir(), `openclaw-missing-denied-cwd-${Date.now()}`);
|
||||
|
||||
const existing = await definition.execute(
|
||||
"call-denied-existing-cwd",
|
||||
{
|
||||
command: "echo denied",
|
||||
workdir: process.cwd(),
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
extensionContext,
|
||||
);
|
||||
const missing = await definition.execute(
|
||||
"call-denied-missing-cwd",
|
||||
{
|
||||
command: "echo denied",
|
||||
workdir: missingWorkdir,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
extensionContext,
|
||||
);
|
||||
|
||||
const expected = {
|
||||
status: "error",
|
||||
error: "exec denied: host=gateway security=deny",
|
||||
};
|
||||
expect(existing.details).toMatchObject(expected);
|
||||
expect(missing.details).toMatchObject(expected);
|
||||
expect(JSON.stringify(missing)).not.toContain("unavailable or not a directory");
|
||||
});
|
||||
|
||||
it("does not validate backend sandbox workdirs before exec deny", async () => {
|
||||
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "deny",
|
||||
ask: "off",
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir: process.cwd(),
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
},
|
||||
});
|
||||
const [definition] = toToolDefinitions([tool]);
|
||||
|
||||
const result = await definition.execute(
|
||||
"call-denied-backend-cwd",
|
||||
{
|
||||
command: "echo denied",
|
||||
workdir: "/remote/workspace/generated",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
extensionContext,
|
||||
);
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "error",
|
||||
error: "exec denied: host=sandbox security=deny",
|
||||
});
|
||||
expect(validateWorkdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw WeakMap errors when preparing malformed exec params", async () => {
|
||||
const tool = createExecTool({
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
const [definition] = toToolDefinitions([tool]);
|
||||
|
||||
const result = await definition.execute(
|
||||
"call-malformed-exec-params",
|
||||
"not-an-object",
|
||||
undefined,
|
||||
undefined,
|
||||
extensionContext,
|
||||
);
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "error",
|
||||
error: "Provide a command to start.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports malformed exec params when elevated logging is enabled", async () => {
|
||||
const tool = createExecTool({
|
||||
security: "full",
|
||||
ask: "off",
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "on" },
|
||||
});
|
||||
const [definition] = toToolDefinitions([tool]);
|
||||
|
||||
const result = await definition.execute(
|
||||
"call-malformed-elevated-exec-params",
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
extensionContext,
|
||||
);
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "error",
|
||||
error: "Provide a command to start.",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not validate backend sandbox workdirs before malformed exec params fail", async () => {
|
||||
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir: process.cwd(),
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
},
|
||||
});
|
||||
const [definition] = toToolDefinitions([tool]);
|
||||
|
||||
const result = await definition.execute(
|
||||
"call-malformed-backend-sandbox-exec-params",
|
||||
{
|
||||
workdir: "/remote/workspace/generated",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
extensionContext,
|
||||
);
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "error",
|
||||
error: "Provide a command to start.",
|
||||
});
|
||||
expect(validateWorkdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coerces details-only tool results to include content", async () => {
|
||||
const tool = {
|
||||
name: "memory_query",
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
* Tests agent-specific exec defaults in assembled coding tools.
|
||||
* Verifies per-agent exec host policy affects lazy exec/process behavior.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import "./test-helpers/fast-openclaw-tools.js";
|
||||
import { createTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js";
|
||||
@@ -49,26 +46,11 @@ function requireExecTool(tools: ReturnType<typeof createOpenClawCodingTools>) {
|
||||
return execTool;
|
||||
}
|
||||
|
||||
const tempDirs = createTempDirTracker();
|
||||
|
||||
function createTempAgentDirs(prefix: string) {
|
||||
const root = tempDirs.make(`${prefix}-`);
|
||||
const workspaceDir = path.join(root, "workspace");
|
||||
const agentDir = path.join(root, "agent");
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
return { workspaceDir, agentDir };
|
||||
}
|
||||
|
||||
describe("Agent-specific exec tool defaults", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createSessionConversationTestRegistry());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tempDirs.cleanup();
|
||||
});
|
||||
|
||||
it("should run exec synchronously when process is denied", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
@@ -84,7 +66,8 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
...createTempAgentDirs("test-main"),
|
||||
workspaceDir: "/tmp/test-main",
|
||||
agentDir: "/tmp/agent-main",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
|
||||
@@ -108,7 +91,8 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
...createTempAgentDirs("test-main-implicit-gateway"),
|
||||
workspaceDir: "/tmp/test-main-implicit-gateway",
|
||||
agentDir: "/tmp/agent-main-implicit-gateway",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
|
||||
@@ -129,7 +113,8 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
...createTempAgentDirs("test-main-mode-deny"),
|
||||
workspaceDir: "/tmp/test-main-mode-deny",
|
||||
agentDir: "/tmp/agent-main-mode-deny",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
|
||||
@@ -150,7 +135,8 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
...createTempAgentDirs("test-main-mode-call-security"),
|
||||
workspaceDir: "/tmp/test-main-mode-call-security",
|
||||
agentDir: "/tmp/agent-main-mode-call-security",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
|
||||
@@ -185,7 +171,8 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
...createTempAgentDirs("test-main-mode-partial-agent"),
|
||||
workspaceDir: "/tmp/test-main-mode-partial-agent",
|
||||
agentDir: "/tmp/agent-main-mode-partial-agent",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
|
||||
@@ -210,7 +197,8 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
security: "deny",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
...createTempAgentDirs("test-main-session-legacy-override"),
|
||||
workspaceDir: "/tmp/test-main-session-legacy-override",
|
||||
agentDir: "/tmp/agent-main-session-legacy-override",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
|
||||
@@ -225,7 +213,8 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: {},
|
||||
sessionKey: "agent:main:main",
|
||||
...createTempAgentDirs("test-main-fail-closed"),
|
||||
workspaceDir: "/tmp/test-main-fail-closed",
|
||||
agentDir: "/tmp/agent-main-fail-closed",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
await expect(
|
||||
@@ -245,7 +234,8 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
const mainTools = createOpenClawCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
...createTempAgentDirs("test-main-exec-defaults"),
|
||||
workspaceDir: "/tmp/test-main-exec-defaults",
|
||||
agentDir: "/tmp/agent-main-exec-defaults",
|
||||
});
|
||||
const mainExecTool = requireExecTool(mainTools);
|
||||
const mainResult = await mainExecTool.execute("call-main-default", {
|
||||
@@ -264,7 +254,8 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
const helperTools = createOpenClawCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:helper:main",
|
||||
...createTempAgentDirs("test-helper-exec-defaults"),
|
||||
workspaceDir: "/tmp/test-helper-exec-defaults",
|
||||
agentDir: "/tmp/agent-helper-exec-defaults",
|
||||
});
|
||||
const helperExecTool = requireExecTool(helperTools);
|
||||
const helperResult = await helperExecTool.execute("call-helper-default", {
|
||||
@@ -289,7 +280,8 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
config: cfg,
|
||||
agentId: "main",
|
||||
sessionKey: "run-opaque-123",
|
||||
...createTempAgentDirs("test-main-opaque-session"),
|
||||
workspaceDir: "/tmp/test-main-opaque-session",
|
||||
agentDir: "/tmp/agent-main-opaque-session",
|
||||
});
|
||||
const execTool = requireExecTool(tools);
|
||||
const result = await execTool.execute("call-main-opaque-session", {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -854,12 +854,6 @@ export function createOpenClawCodingTools(options?: {
|
||||
containerName: sandbox.containerName,
|
||||
workspaceDir: sandbox.workspaceDir,
|
||||
containerWorkdir: sandbox.containerWorkdir,
|
||||
workdirValidation: sandbox.backend?.workdirValidation,
|
||||
validateWorkdir: sandbox.backend?.validateWorkdir?.bind(sandbox.backend),
|
||||
discardPreparedWorkdir: sandbox.backend?.discardPreparedWorkdir?.bind(
|
||||
sandbox.backend,
|
||||
),
|
||||
workdirRoots: sandbox.backend?.workdirRoots,
|
||||
env: sandbox.backend?.env ?? sandbox.docker.env,
|
||||
buildExecSpec: sandbox.backend?.buildExecSpec.bind(sandbox.backend),
|
||||
finalizeExec: sandbox.backend?.finalizeExec?.bind(sandbox.backend),
|
||||
|
||||
@@ -3,23 +3,18 @@
|
||||
* Verifies failed process outcomes surface useful text/details for shell
|
||||
* errors, timeouts, signals, and runtime failures.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import type { ProcessSupervisor, SpawnInput } from "../process/supervisor/index.js";
|
||||
import type { SpawnInput } from "../process/supervisor/index.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
import { createExecTool } from "./bash-tools.exec.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
import { resolveShellFromPath } from "./shell-utils.js";
|
||||
|
||||
const supervisorMock = vi.hoisted(() => ({
|
||||
spawn: vi.fn<ProcessSupervisor["spawn"]>(),
|
||||
cancel: vi.fn<ProcessSupervisor["cancel"]>(),
|
||||
cancelScope: vi.fn<ProcessSupervisor["cancelScope"]>(),
|
||||
getRecord: vi.fn<ProcessSupervisor["getRecord"]>(),
|
||||
spawn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
getRecord: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../process/supervisor/index.js", () => ({
|
||||
@@ -30,7 +25,6 @@ const isWin = process.platform === "win32";
|
||||
const defaultShell = isWin
|
||||
? undefined
|
||||
: process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh";
|
||||
const tempDirs = createTempDirTracker();
|
||||
|
||||
function requireTextContent(
|
||||
result: Awaited<ReturnType<ReturnType<typeof createExecTool>["execute"]>>,
|
||||
@@ -53,66 +47,6 @@ function requireFailedDetails(
|
||||
return details;
|
||||
}
|
||||
|
||||
function mockSuccessfulSpawn(stdout = "ok\n") {
|
||||
supervisorMock.spawn.mockImplementationOnce(async (input: SpawnInput) => ({
|
||||
runId: input.runId ?? "call-success",
|
||||
pid: 1234,
|
||||
startedAtMs: Date.now(),
|
||||
stdin: {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
},
|
||||
wait: vi.fn(async () => ({
|
||||
reason: "exit" as const,
|
||||
exitCode: 0,
|
||||
exitSignal: null,
|
||||
durationMs: 1,
|
||||
stdout,
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
})),
|
||||
cancel: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function expectUnavailableWorkdir(params: {
|
||||
workdir: string;
|
||||
toolDefaults?: Parameters<typeof createExecTool>[0];
|
||||
executeArgs?: Partial<Parameters<ReturnType<typeof createExecTool>["execute"]>[1]>;
|
||||
cleanup?: () => void;
|
||||
}) {
|
||||
const tool = createExecTool({
|
||||
security: "full",
|
||||
ask: "off",
|
||||
allowBackground: false,
|
||||
...params.toolDefaults,
|
||||
});
|
||||
|
||||
try {
|
||||
const executeArgs = params.executeArgs ?? { workdir: params.workdir };
|
||||
const result = await tool.execute("call-unavailable-workdir", {
|
||||
command: "echo should-not-run",
|
||||
...executeArgs,
|
||||
});
|
||||
|
||||
const text = requireTextContent(result);
|
||||
expect(text).toContain(`workdir "${params.workdir}" is unavailable or not a directory`);
|
||||
expect(text).toContain("command was not executed");
|
||||
expect(text).toContain("workdir is treated as a literal path");
|
||||
expect(text).toContain('shell expansions such as "~" are not applied');
|
||||
const details = requireFailedDetails(result.details);
|
||||
expect(details.exitCode).toBeNull();
|
||||
expect(details.timedOut).toBe(false);
|
||||
expect(details.aggregated).toBe("");
|
||||
expect(details.cwd).toBe(params.workdir);
|
||||
expect(supervisorMock.spawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
params.cleanup?.();
|
||||
}
|
||||
}
|
||||
|
||||
describe("exec foreground failures", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
|
||||
|
||||
@@ -133,7 +67,6 @@ describe("exec foreground failures", () => {
|
||||
vi.useRealTimers();
|
||||
envSnapshot?.restore();
|
||||
envSnapshot = undefined;
|
||||
tempDirs.cleanup();
|
||||
});
|
||||
|
||||
it("returns a failed text result when the default timeout is exceeded", async () => {
|
||||
@@ -211,374 +144,4 @@ describe("exec foreground failures", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a failed result for unavailable explicit host workdirs before launching", async () => {
|
||||
const missingWorkdir = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-missing-workdir-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
fs.rmSync(missingWorkdir, { recursive: true, force: true });
|
||||
|
||||
const fileWorkdir = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-file-workdir-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
fs.writeFileSync(fileWorkdir, "not a directory");
|
||||
|
||||
try {
|
||||
for (const workdir of [missingWorkdir, " ", fileWorkdir]) {
|
||||
await expectUnavailableWorkdir({ workdir });
|
||||
supervisorMock.spawn.mockClear();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(fileWorkdir, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a failed result for unavailable configured host workdirs before launching", async () => {
|
||||
const missingDefaultWorkdir = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-missing-default-workdir-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
fs.rmSync(missingDefaultWorkdir, { recursive: true, force: true });
|
||||
|
||||
await expectUnavailableWorkdir({
|
||||
workdir: missingDefaultWorkdir,
|
||||
toolDefaults: { cwd: missingDefaultWorkdir },
|
||||
executeArgs: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a failed result when the current gateway cwd is unavailable", async () => {
|
||||
const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => {
|
||||
throw new Error("current cwd unavailable");
|
||||
});
|
||||
try {
|
||||
await expectUnavailableWorkdir({
|
||||
workdir: "current working directory",
|
||||
executeArgs: {},
|
||||
});
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a failed result for unavailable configured sandbox workdirs before launching", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
|
||||
try {
|
||||
await expectUnavailableWorkdir({
|
||||
workdir: "/workspace/missing",
|
||||
toolDefaults: {
|
||||
cwd: "/workspace/missing",
|
||||
host: "sandbox",
|
||||
sandbox: {
|
||||
containerName: "sandbox-workdir-test",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/workspace",
|
||||
},
|
||||
},
|
||||
executeArgs: {},
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults omitted sandbox workdirs to the sandbox workspace", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
|
||||
mockSuccessfulSpawn();
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
allowBackground: false,
|
||||
sandbox: {
|
||||
containerName: "sandbox-workdir-test",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/workspace",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await tool.execute("call-sandbox-default-workdir", {
|
||||
command: "echo ok",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(result.details.cwd).toBe(workspaceDir);
|
||||
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
|
||||
const input = supervisorMock.spawn.mock.calls[0]?.[0];
|
||||
expect(input?.cwd).toBe(workspaceDir);
|
||||
expect(input?.mode).toBe("child");
|
||||
if (input?.mode === "child") {
|
||||
expect(input.argv).toContain("-w");
|
||||
expect(input.argv).toContain("/workspace");
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("lets backend-validated sandbox workdirs reach the backend without host stat fallback", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
|
||||
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
|
||||
async (params) => ({
|
||||
argv: ["remote-shell", params.command],
|
||||
env: {},
|
||||
stdinMode: "pipe-open" as const,
|
||||
}),
|
||||
);
|
||||
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
|
||||
async (workdir) => workdir,
|
||||
);
|
||||
mockSuccessfulSpawn();
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
allowBackground: false,
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
buildExecSpec,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await tool.execute("call-remote-sandbox-workdir", {
|
||||
command: "echo ok",
|
||||
workdir: "/remote/workspace/generated",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(result.details.cwd).toBe(workspaceDir);
|
||||
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
|
||||
expect(buildExecSpec).toHaveBeenCalledOnce();
|
||||
expect(buildExecSpec.mock.calls[0]?.[0]?.workdir).toBe("/remote/workspace/generated");
|
||||
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
|
||||
expect(supervisorMock.spawn.mock.calls[0]?.[0]?.cwd).toBe(workspaceDir);
|
||||
} finally {
|
||||
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsafe commands before backend workdir validation", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
|
||||
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
|
||||
async (params) => ({
|
||||
argv: ["remote-shell", params.command],
|
||||
env: {},
|
||||
stdinMode: "pipe-open" as const,
|
||||
}),
|
||||
);
|
||||
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
|
||||
async (workdir) => workdir,
|
||||
);
|
||||
const discardPreparedWorkdir =
|
||||
vi.fn<NonNullable<BashSandboxConfig["discardPreparedWorkdir"]>>();
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
allowBackground: false,
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
discardPreparedWorkdir,
|
||||
buildExecSpec,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
tool.execute("call-remote-sandbox-rejected-command", {
|
||||
command: "/approve approval-1 deny",
|
||||
workdir: "/remote/workspace/generated",
|
||||
}),
|
||||
).rejects.toThrow("exec cannot run /approve commands");
|
||||
|
||||
expect(validateWorkdir).not.toHaveBeenCalled();
|
||||
expect(discardPreparedWorkdir).not.toHaveBeenCalled();
|
||||
expect(buildExecSpec).not.toHaveBeenCalled();
|
||||
expect(supervisorMock.spawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not preflight remote-only backend workdirs from the local workspace root", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
|
||||
fs.writeFileSync(path.join(workspaceDir, "script.py"), "print($TOKEN)\n");
|
||||
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
|
||||
async (params) => ({
|
||||
argv: ["remote-shell", params.command],
|
||||
env: {},
|
||||
stdinMode: "pipe-open" as const,
|
||||
}),
|
||||
);
|
||||
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
|
||||
async (workdir) => workdir,
|
||||
);
|
||||
mockSuccessfulSpawn();
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
allowBackground: false,
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
buildExecSpec,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await tool.execute("call-remote-only-script", {
|
||||
command: "python script.py",
|
||||
workdir: "/remote/workspace/generated",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
|
||||
expect(buildExecSpec).toHaveBeenCalledOnce();
|
||||
expect(buildExecSpec.mock.calls[0]?.[0]?.workdir).toBe("/remote/workspace/generated");
|
||||
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the mapped host cwd for existing relative backend-validated sandbox workdirs", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
|
||||
const srcDir = path.join(workspaceDir, "src");
|
||||
fs.mkdirSync(srcDir);
|
||||
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
|
||||
async (params) => ({
|
||||
argv: ["remote-shell", params.command],
|
||||
env: {},
|
||||
stdinMode: "pipe-open" as const,
|
||||
}),
|
||||
);
|
||||
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
|
||||
async (workdir) => workdir,
|
||||
);
|
||||
mockSuccessfulSpawn();
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
allowBackground: false,
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
buildExecSpec,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await tool.execute("call-relative-remote-sandbox-workdir", {
|
||||
command: "echo ok",
|
||||
workdir: "src",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(result.details.cwd).toBe(srcDir);
|
||||
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/src");
|
||||
expect(buildExecSpec).toHaveBeenCalledOnce();
|
||||
expect(buildExecSpec.mock.calls[0]?.[0]?.workdir).toBe("/remote/workspace/src");
|
||||
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
|
||||
expect(supervisorMock.spawn.mock.calls[0]?.[0]?.cwd).toBe(srcDir);
|
||||
} finally {
|
||||
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails backend-validated sandbox workdirs before launch when backend validation rejects", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
|
||||
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
|
||||
async () => null,
|
||||
);
|
||||
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
|
||||
async (params) => ({
|
||||
argv: ["remote-shell", params.command],
|
||||
env: {},
|
||||
stdinMode: "pipe-open" as const,
|
||||
}),
|
||||
);
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
allowBackground: false,
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
buildExecSpec,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await tool.execute("call-remote-sandbox-workdir", {
|
||||
command: "echo ok",
|
||||
workdir: "/remote/workspace/generated",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "failed",
|
||||
cwd: "/remote/workspace/generated",
|
||||
});
|
||||
expect(JSON.stringify(result)).toContain("unavailable or not a directory");
|
||||
expect(validateWorkdir).toHaveBeenCalledOnce();
|
||||
expect(buildExecSpec).not.toHaveBeenCalled();
|
||||
expect(supervisorMock.spawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a failed result for unavailable explicit sandbox workdirs before launching a command", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
|
||||
const outsideDir = tempDirs.make("openclaw-outside-workdir-");
|
||||
fs.writeFileSync(path.join(workspaceDir, "not-dir"), "not a directory");
|
||||
try {
|
||||
for (const workdir of ["/workspace/missing", " ", "/workspace/not-dir", outsideDir]) {
|
||||
await expectUnavailableWorkdir({
|
||||
workdir,
|
||||
toolDefaults: {
|
||||
host: "sandbox",
|
||||
sandbox: {
|
||||
containerName: "sandbox-workdir-test",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/workspace",
|
||||
},
|
||||
},
|
||||
});
|
||||
supervisorMock.spawn.mockClear();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2716,10 +2716,7 @@ describe("executeNodeHostCommand", () => {
|
||||
expect(requireGatewayCommand("system.run.prepare").params?.params?.env).toEqual({
|
||||
FOO: "bar",
|
||||
});
|
||||
expect(requireGatewayCommand("system.run.prepare").params?.params?.cwd).toBe("/tmp/work");
|
||||
const runParams = requireRunParams(requireGatewayCommand("system.run"));
|
||||
expect(runParams.env).toEqual({ FOO: "bar" });
|
||||
expect(runParams.cwd).toBe("/tmp/work");
|
||||
expect(requireRunParams(requireGatewayCommand("system.run")).env).toEqual({ FOO: "bar" });
|
||||
const evalEnvs = evaluateShellAllowlistMock.mock.calls.map(
|
||||
([raw]) => (raw as ShellAllowlistMockParams).env,
|
||||
);
|
||||
@@ -2748,31 +2745,12 @@ describe("executeNodeHostCommand", () => {
|
||||
const runParams = requireRunParams(call);
|
||||
expect(runParams.command).toEqual(["/bin/sh", "-lc", "bun ./script.ts"]);
|
||||
expect(runParams.rawCommand).toBe("bun ./script.ts");
|
||||
expect(runParams.cwd).toBe("/tmp/work");
|
||||
expect(typeof runParams.runId).toBe("string");
|
||||
expect(runParams.suppressNotifyOnExit).toBe(true);
|
||||
expect(runParams.timeoutMs).toBe(30_000);
|
||||
expect(Object.hasOwn(runParams, "systemRunPlan")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits cwd from direct node system.run when workdir is undefined", async () => {
|
||||
await executeNodeHostCommand({
|
||||
command: "bun ./script.ts",
|
||||
workdir: undefined,
|
||||
env: {},
|
||||
security: "full",
|
||||
ask: "off",
|
||||
defaultTimeoutSec: 30,
|
||||
approvalRunningNoticeMs: 0,
|
||||
warnings: [],
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
});
|
||||
|
||||
const runParams = requireRunParams(requireGatewayCall(0));
|
||||
expect(Object.hasOwn(runParams, "cwd")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects disconnected node targets before invoking system.run", async () => {
|
||||
listNodesMock.mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -1,616 +0,0 @@
|
||||
/**
|
||||
* Exec workdir resolver tests.
|
||||
* Verifies cwd selection and validation before exec launches or remote node
|
||||
* forwarding.
|
||||
*/
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveExecWorkdir } from "./bash-tools.exec-workdir.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
|
||||
async function withTempDir(run: (dir: string) => Promise<void>) {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-exec-workdir-"));
|
||||
try {
|
||||
await run(dir);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function sandboxConfig(workspaceDir: string): BashSandboxConfig {
|
||||
return {
|
||||
containerName: "sandbox-workdir-test",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/workspace",
|
||||
};
|
||||
}
|
||||
|
||||
function backendSandboxConfig(
|
||||
workspaceDir: string,
|
||||
params?: {
|
||||
containerWorkdir?: string;
|
||||
workdirRoots?: readonly string[];
|
||||
validateWorkdir?: BashSandboxConfig["validateWorkdir"];
|
||||
},
|
||||
): BashSandboxConfig {
|
||||
return {
|
||||
...sandboxConfig(workspaceDir),
|
||||
containerWorkdir: params?.containerWorkdir ?? "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
workdirRoots: params?.workdirRoots,
|
||||
validateWorkdir: params?.validateWorkdir ?? (async (workdir) => workdir),
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveExecWorkdir", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rejects blank explicit local workdirs", async () => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "gateway",
|
||||
workdir: " ",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: " " });
|
||||
});
|
||||
|
||||
it("rejects missing explicit local workdirs without fallback", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const missing = path.join(workspaceDir, "missing");
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "gateway",
|
||||
workdir: missing,
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: missing });
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects file explicit local workdirs", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const fileWorkdir = path.join(workspaceDir, "not-dir");
|
||||
await writeFile(fileWorkdir, "not a directory");
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "gateway",
|
||||
workdir: fileWorkdir,
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: fileWorkdir });
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves valid explicit local workdirs", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "gateway",
|
||||
workdir: ` ${workspaceDir} `,
|
||||
}),
|
||||
).resolves.toEqual({ kind: "local", hostCwd: workspaceDir });
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured local cwd when workdir is omitted", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "gateway",
|
||||
defaultCwd: workspaceDir,
|
||||
}),
|
||||
).resolves.toEqual({ kind: "local", hostCwd: workspaceDir });
|
||||
});
|
||||
});
|
||||
|
||||
it("uses current cwd for omitted local workdir only when no default exists", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "gateway",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "local", hostCwd: workspaceDir });
|
||||
});
|
||||
});
|
||||
|
||||
it("fails omitted local workdir when current cwd is unavailable", async () => {
|
||||
vi.spyOn(process, "cwd").mockImplementation(() => {
|
||||
throw new Error("cwd unavailable");
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "gateway",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: "current working directory" });
|
||||
});
|
||||
|
||||
it("rejects missing configured local cwd without falling back to current cwd", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const missingDefault = path.join(workspaceDir, "missing-default");
|
||||
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "gateway",
|
||||
defaultCwd: missingDefault,
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: missingDefault });
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the sandbox workspace when sandbox workdir is omitted", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
sandbox: sandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: workspaceDir,
|
||||
containerCwd: "/workspace",
|
||||
scriptPreflightCwd: workspaceDir,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing explicit sandbox workdirs", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/workspace/missing",
|
||||
sandbox: sandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: "/workspace/missing" });
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing configured sandbox workdirs", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
defaultCwd: "/workspace/missing",
|
||||
sandbox: sandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: "/workspace/missing" });
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects file sandbox workdirs", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await writeFile(path.join(workspaceDir, "not-dir"), "not a directory");
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/workspace/not-dir",
|
||||
sandbox: sandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: "/workspace/not-dir" });
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects sandbox workdirs that escape the workspace", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await withTempDir(async (outsideDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: outsideDir,
|
||||
sandbox: sandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: outsideDir });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects sandbox workdirs with parent-directory segments", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "missing/..",
|
||||
sandbox: sandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: "missing/.." });
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/workspace/missing/..",
|
||||
sandbox: sandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: "/workspace/missing/.." });
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects sandbox workdir symlinks that escape the workspace", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await withTempDir(async (outsideDir) => {
|
||||
await symlink(outsideDir, path.join(workspaceDir, "escape"), "dir");
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/workspace/escape",
|
||||
sandbox: sandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: "/workspace/escape" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves relative sandbox workdirs under the workspace", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const srcDir = path.join(workspaceDir, "src");
|
||||
await mkdir(srcDir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "src",
|
||||
sandbox: sandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: srcDir,
|
||||
containerCwd: "/workspace/src",
|
||||
scriptPreflightCwd: srcDir,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("supports custom sandbox container workdir prefixes", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const projectDir = path.join(workspaceDir, "project");
|
||||
await mkdir(projectDir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/sandbox-root/project",
|
||||
sandbox: {
|
||||
...sandboxConfig(workspaceDir),
|
||||
containerWorkdir: "/sandbox-root",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: projectDir,
|
||||
containerCwd: "/sandbox-root/project",
|
||||
scriptPreflightCwd: projectDir,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("lets backend-validated sandboxes use remote-only container workdirs", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/remote/workspace/generated",
|
||||
sandbox: backendSandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: workspaceDir,
|
||||
containerCwd: "/remote/workspace/generated",
|
||||
scriptPreflightCwd: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes backend-validated sandbox workdir roots with trailing slashes", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/remote/workspace/generated",
|
||||
sandbox: backendSandboxConfig(workspaceDir, {
|
||||
containerWorkdir: "/remote/workspace/",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: workspaceDir,
|
||||
containerCwd: "/remote/workspace/generated",
|
||||
scriptPreflightCwd: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("lets backend-validated sandboxes use declared alternate remote roots", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/agent/project",
|
||||
sandbox: backendSandboxConfig(workspaceDir, {
|
||||
workdirRoots: ["/agent"],
|
||||
validateWorkdir,
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: workspaceDir,
|
||||
containerCwd: "/agent/project",
|
||||
scriptPreflightCwd: null,
|
||||
});
|
||||
expect(validateWorkdir).toHaveBeenCalledWith("/agent/project");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves relative backend-validated sandbox workdirs under the remote workspace", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "remote-only",
|
||||
sandbox: backendSandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: workspaceDir,
|
||||
containerCwd: "/remote/workspace/remote-only",
|
||||
scriptPreflightCwd: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps existing relative backend-validated sandbox workdirs aligned with the local mirror", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const localDir = path.join(workspaceDir, "src");
|
||||
await mkdir(localDir);
|
||||
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "src",
|
||||
sandbox: backendSandboxConfig(workspaceDir, { validateWorkdir }),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: localDir,
|
||||
containerCwd: "/remote/workspace/src",
|
||||
scriptPreflightCwd: localDir,
|
||||
});
|
||||
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/src");
|
||||
});
|
||||
});
|
||||
|
||||
it("defers stale relative backend-validated sandbox workdirs to the backend", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const localFile = path.join(workspaceDir, "build");
|
||||
await writeFile(localFile, "stale local mirror file");
|
||||
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "build",
|
||||
sandbox: backendSandboxConfig(workspaceDir, { validateWorkdir }),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: workspaceDir,
|
||||
containerCwd: "/remote/workspace/build",
|
||||
scriptPreflightCwd: null,
|
||||
});
|
||||
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/build");
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts backend-validated absolute workdirs when the remote workspace root is slash", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/generated",
|
||||
sandbox: backendSandboxConfig(workspaceDir, {
|
||||
containerWorkdir: "/",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: workspaceDir,
|
||||
containerCwd: "/generated",
|
||||
scriptPreflightCwd: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("maps host workspace paths for backend-validated sandboxes when they exist locally", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const localDir = path.join(workspaceDir, "src");
|
||||
await mkdir(localDir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: localDir,
|
||||
sandbox: backendSandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: localDir,
|
||||
containerCwd: "/remote/workspace/src",
|
||||
scriptPreflightCwd: localDir,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("defers missing absolute backend workdirs to remote validation when roots overlap", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const missingRemoteDir = path.join(workspaceDir, "generated");
|
||||
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: missingRemoteDir,
|
||||
sandbox: backendSandboxConfig(workspaceDir, {
|
||||
containerWorkdir: workspaceDir,
|
||||
validateWorkdir,
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: workspaceDir,
|
||||
containerCwd: missingRemoteDir,
|
||||
scriptPreflightCwd: null,
|
||||
});
|
||||
expect(validateWorkdir).toHaveBeenCalledWith(missingRemoteDir);
|
||||
});
|
||||
});
|
||||
|
||||
it("maps missing absolute host workspace paths before backend validation", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const missingRemoteDir = path.join(workspaceDir, "generated");
|
||||
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: missingRemoteDir,
|
||||
sandbox: backendSandboxConfig(workspaceDir, {
|
||||
validateWorkdir,
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: workspaceDir,
|
||||
containerCwd: "/remote/workspace/generated",
|
||||
scriptPreflightCwd: null,
|
||||
});
|
||||
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects backend-validated sandbox host paths that symlink outside the workspace", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await withTempDir(async (outsideDir) => {
|
||||
const escape = path.join(workspaceDir, "escape");
|
||||
await symlink(outsideDir, escape, "dir");
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: escape,
|
||||
sandbox: backendSandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "unavailable",
|
||||
requestedCwd: escape,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers existing host workspace paths over matching backend container prefixes", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const localDir = path.join(workspaceDir, "src");
|
||||
await mkdir(localDir);
|
||||
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: localDir,
|
||||
sandbox: backendSandboxConfig(workspaceDir, {
|
||||
containerWorkdir: workspaceDir,
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "sandbox",
|
||||
hostCwd: localDir,
|
||||
containerCwd: `${workspaceDir}/src`,
|
||||
scriptPreflightCwd: localDir,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects backend-validated sandbox workdirs outside local and remote workspace roots", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/other/remote/workspace",
|
||||
sandbox: backendSandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "unavailable",
|
||||
requestedCwd: "/other/remote/workspace",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects backend-validated sandbox workdirs with parent-directory segments", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/remote/workspace/missing/..",
|
||||
sandbox: backendSandboxConfig(workspaceDir),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "unavailable",
|
||||
requestedCwd: "/remote/workspace/missing/..",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects backend-validated sandbox workdirs when the backend validator fails", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "sandbox",
|
||||
workdir: "/remote/workspace/missing",
|
||||
sandbox: backendSandboxConfig(workspaceDir, {
|
||||
validateWorkdir: async () => null,
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "unavailable",
|
||||
requestedCwd: "/remote/workspace/missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("omits node cwd when node workdir is omitted", async () => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "node",
|
||||
defaultCwd: "/gateway/default",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "node" });
|
||||
});
|
||||
|
||||
it("forwards explicit node cwd without local validation", async () => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "node",
|
||||
workdir: "/remote/node/workspace",
|
||||
defaultCwd: "/gateway/default",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "node", remoteCwd: "/remote/node/workspace" });
|
||||
});
|
||||
|
||||
it("rejects blank explicit node workdirs", async () => {
|
||||
await expect(
|
||||
resolveExecWorkdir({
|
||||
host: "node",
|
||||
workdir: " ",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "unavailable", requestedCwd: " " });
|
||||
});
|
||||
});
|
||||
@@ -1,381 +0,0 @@
|
||||
/**
|
||||
* Internal exec workdir resolver.
|
||||
* Owns cwd selection and validation before exec approval, hooks, preflight, or
|
||||
* process launch can observe an invalid selected working directory.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { ExecHost } from "../infra/exec-approvals.js";
|
||||
import { safeStatSync } from "../infra/path-guards.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
|
||||
export type ExecWorkdirResolution =
|
||||
| { kind: "local"; hostCwd: string }
|
||||
| { kind: "sandbox"; hostCwd: string; containerCwd: string; scriptPreflightCwd: string | null }
|
||||
| { kind: "node"; remoteCwd?: string }
|
||||
| { kind: "unavailable"; requestedCwd: string };
|
||||
|
||||
type NormalizedWorkdirInput =
|
||||
| { kind: "omitted" }
|
||||
| { kind: "blank"; raw: string }
|
||||
| { kind: "specified"; value: string };
|
||||
|
||||
type SandboxWorkdir = {
|
||||
hostCwd: string;
|
||||
containerCwd: string;
|
||||
scriptPreflightCwd: string | null;
|
||||
};
|
||||
|
||||
type BackendHostWorkdirCandidate = {
|
||||
hostPath: string;
|
||||
failIfInvalid: boolean;
|
||||
};
|
||||
|
||||
type ExistingHostWorkspacePathResult =
|
||||
| { kind: "available"; workdir: SandboxWorkdir }
|
||||
| { kind: "missing"; relative: string }
|
||||
| { kind: "invalid" };
|
||||
|
||||
function normalizeExplicitWorkdirInput(workdir: string | undefined): NormalizedWorkdirInput {
|
||||
if (workdir === undefined) {
|
||||
return { kind: "omitted" };
|
||||
}
|
||||
const value = normalizeOptionalString(workdir);
|
||||
return value ? { kind: "specified", value } : { kind: "blank", raw: workdir };
|
||||
}
|
||||
|
||||
function unavailable(requestedCwd: string): ExecWorkdirResolution {
|
||||
return { kind: "unavailable", requestedCwd };
|
||||
}
|
||||
|
||||
function resolveExistingHostWorkdir(workdir: string): string | null {
|
||||
const stats = safeStatSync(workdir);
|
||||
return stats?.isDirectory() ? workdir : null;
|
||||
}
|
||||
|
||||
function isHostPathInsideRoot(params: { root: string; candidate: string }): boolean {
|
||||
const root = path.resolve(params.root);
|
||||
const candidate = path.resolve(params.candidate);
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function safeCurrentCwd(): string | null {
|
||||
try {
|
||||
return process.cwd();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapContainerWorkdirToHost(params: {
|
||||
workdir: string;
|
||||
sandbox: BashSandboxConfig;
|
||||
}): string | undefined {
|
||||
const workdir = normalizeContainerPath(params.workdir);
|
||||
const containerRoot = normalizeContainerPath(params.sandbox.containerWorkdir);
|
||||
if (containerRoot === ".") {
|
||||
return undefined;
|
||||
}
|
||||
if (workdir === containerRoot) {
|
||||
return path.resolve(params.sandbox.workspaceDir);
|
||||
}
|
||||
if (!workdir.startsWith(`${containerRoot}/`)) {
|
||||
return undefined;
|
||||
}
|
||||
const rel = workdir
|
||||
.slice(containerRoot.length + 1)
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
return path.resolve(params.sandbox.workspaceDir, ...rel);
|
||||
}
|
||||
|
||||
function normalizeContainerPath(input: string): string {
|
||||
const normalized = input.trim().replace(/\\/g, "/");
|
||||
if (!normalized) {
|
||||
return ".";
|
||||
}
|
||||
const posixPath = path.posix.normalize(normalized);
|
||||
return posixPath === "/" ? posixPath : posixPath.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function joinContainerWorkdir(containerWorkdir: string, relative: string): string {
|
||||
return relative ? path.posix.join(containerWorkdir, relative) : containerWorkdir;
|
||||
}
|
||||
|
||||
function hasParentPathSegment(input: string): boolean {
|
||||
return input
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.some((segment) => segment === "..");
|
||||
}
|
||||
|
||||
function isContainerWorkdirInsideRoot(params: { root: string; workdir: string }): boolean {
|
||||
const root = normalizeContainerPath(params.root);
|
||||
const workdir = normalizeContainerPath(params.workdir);
|
||||
if (root === "/") {
|
||||
return path.posix.isAbsolute(workdir);
|
||||
}
|
||||
return workdir === root || workdir.startsWith(`${root}/`);
|
||||
}
|
||||
|
||||
function resolveBackendWorkdirRoots(sandbox: BashSandboxConfig): string[] {
|
||||
const roots: string[] = [];
|
||||
const addRoot = (root: string | undefined) => {
|
||||
const normalized = normalizeContainerPath(root ?? "");
|
||||
if (normalized === "." || !path.posix.isAbsolute(normalized) || roots.includes(normalized)) {
|
||||
return;
|
||||
}
|
||||
roots.push(normalized);
|
||||
};
|
||||
addRoot(sandbox.containerWorkdir);
|
||||
for (const root of sandbox.workdirRoots ?? []) {
|
||||
addRoot(root);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function resolveBackendContainerWorkdir(params: {
|
||||
workdir: string;
|
||||
sandbox: BashSandboxConfig;
|
||||
}): string | null {
|
||||
const containerRoot = normalizeContainerPath(params.sandbox.containerWorkdir);
|
||||
const backendRoots = resolveBackendWorkdirRoots(params.sandbox);
|
||||
const requested = normalizeContainerPath(params.workdir);
|
||||
if (path.posix.isAbsolute(requested)) {
|
||||
return backendRoots.some((root) => isContainerWorkdirInsideRoot({ root, workdir: requested }))
|
||||
? requested
|
||||
: null;
|
||||
}
|
||||
if (requested === ".." || requested.startsWith("../")) {
|
||||
return null;
|
||||
}
|
||||
return joinContainerWorkdir(containerRoot, requested === "." ? "" : requested);
|
||||
}
|
||||
|
||||
async function mapExistingHostWorkspacePath(params: {
|
||||
hostPath: string;
|
||||
sandbox: BashSandboxConfig;
|
||||
}): Promise<ExistingHostWorkspacePathResult> {
|
||||
let resolved: Awaited<ReturnType<typeof assertSandboxPath>>;
|
||||
try {
|
||||
resolved = await assertSandboxPath({
|
||||
filePath: params.hostPath,
|
||||
cwd: params.sandbox.workspaceDir,
|
||||
root: params.sandbox.workspaceDir,
|
||||
});
|
||||
} catch {
|
||||
return { kind: "invalid" };
|
||||
}
|
||||
const stats = safeStatSync(resolved.resolved);
|
||||
if (!stats) {
|
||||
return {
|
||||
kind: "missing",
|
||||
relative: resolved.relative ? resolved.relative.split(path.sep).join(path.posix.sep) : "",
|
||||
};
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
return { kind: "invalid" };
|
||||
}
|
||||
const relative = resolved.relative ? resolved.relative.split(path.sep).join(path.posix.sep) : "";
|
||||
return {
|
||||
kind: "available",
|
||||
workdir: {
|
||||
hostCwd: resolved.resolved,
|
||||
containerCwd: joinContainerWorkdir(params.sandbox.containerWorkdir, relative),
|
||||
scriptPreflightCwd: resolved.resolved,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function validateBackendWorkdir(params: {
|
||||
workdir: SandboxWorkdir;
|
||||
sandbox: BashSandboxConfig;
|
||||
}): Promise<SandboxWorkdir | null> {
|
||||
const containerCwd = await params.sandbox.validateWorkdir?.(params.workdir.containerCwd);
|
||||
return containerCwd
|
||||
? {
|
||||
hostCwd: params.workdir.hostCwd,
|
||||
containerCwd,
|
||||
scriptPreflightCwd: params.workdir.scriptPreflightCwd,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveBackendHostWorkdirCandidate(params: {
|
||||
workdir: string;
|
||||
sandbox: BashSandboxConfig;
|
||||
}): BackendHostWorkdirCandidate | null {
|
||||
if (!path.isAbsolute(params.workdir)) {
|
||||
return {
|
||||
hostPath: path.resolve(params.sandbox.workspaceDir, params.workdir),
|
||||
failIfInvalid: false,
|
||||
};
|
||||
}
|
||||
const hostPath = path.resolve(params.workdir);
|
||||
if (
|
||||
isHostPathInsideRoot({
|
||||
root: params.sandbox.workspaceDir,
|
||||
candidate: hostPath,
|
||||
})
|
||||
) {
|
||||
return { hostPath, failIfInvalid: true };
|
||||
}
|
||||
const containerMappedHostPath = mapContainerWorkdirToHost({
|
||||
workdir: params.workdir,
|
||||
sandbox: params.sandbox,
|
||||
});
|
||||
return containerMappedHostPath
|
||||
? { hostPath: containerMappedHostPath, failIfInvalid: false }
|
||||
: null;
|
||||
}
|
||||
|
||||
async function resolveBackendValidatedSandboxWorkdir(params: {
|
||||
workdir: string;
|
||||
sandbox: BashSandboxConfig;
|
||||
}): Promise<SandboxWorkdir | null> {
|
||||
const workspaceHostCwd = resolveExistingHostWorkdir(params.sandbox.workspaceDir);
|
||||
if (!workspaceHostCwd) {
|
||||
return null;
|
||||
}
|
||||
const hostCandidate = resolveBackendHostWorkdirCandidate(params);
|
||||
if (hostCandidate) {
|
||||
const mappedWorkdir = await mapExistingHostWorkspacePath({
|
||||
hostPath: hostCandidate.hostPath,
|
||||
sandbox: params.sandbox,
|
||||
});
|
||||
if (mappedWorkdir.kind === "available") {
|
||||
return await validateBackendWorkdir({
|
||||
workdir: mappedWorkdir.workdir,
|
||||
sandbox: params.sandbox,
|
||||
});
|
||||
}
|
||||
if (mappedWorkdir.kind === "missing") {
|
||||
return await validateBackendWorkdir({
|
||||
workdir: {
|
||||
hostCwd: workspaceHostCwd,
|
||||
containerCwd: joinContainerWorkdir(
|
||||
params.sandbox.containerWorkdir,
|
||||
mappedWorkdir.relative,
|
||||
),
|
||||
scriptPreflightCwd: null,
|
||||
},
|
||||
sandbox: params.sandbox,
|
||||
});
|
||||
}
|
||||
if (hostCandidate.failIfInvalid && mappedWorkdir.kind === "invalid") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const containerCwd = resolveBackendContainerWorkdir(params);
|
||||
if (containerCwd) {
|
||||
return await validateBackendWorkdir({
|
||||
workdir: {
|
||||
hostCwd: workspaceHostCwd,
|
||||
containerCwd,
|
||||
scriptPreflightCwd: null,
|
||||
},
|
||||
sandbox: params.sandbox,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveHostValidatedSandboxWorkdir(params: {
|
||||
workdir: string;
|
||||
sandbox: BashSandboxConfig;
|
||||
}): Promise<SandboxWorkdir | null> {
|
||||
const mappedHostWorkdir = mapContainerWorkdirToHost({
|
||||
workdir: params.workdir,
|
||||
sandbox: params.sandbox,
|
||||
});
|
||||
const candidateWorkdir = mappedHostWorkdir ?? params.workdir;
|
||||
try {
|
||||
const resolved = await assertSandboxPath({
|
||||
filePath: candidateWorkdir,
|
||||
cwd: params.sandbox.workspaceDir,
|
||||
root: params.sandbox.workspaceDir,
|
||||
});
|
||||
const stats = await fs.stat(resolved.resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
const relative = resolved.relative
|
||||
? resolved.relative.split(path.sep).join(path.posix.sep)
|
||||
: "";
|
||||
const containerCwd = joinContainerWorkdir(params.sandbox.containerWorkdir, relative);
|
||||
return { hostCwd: resolved.resolved, containerCwd, scriptPreflightCwd: resolved.resolved };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSandboxWorkdir(params: {
|
||||
workdir: string;
|
||||
sandbox: BashSandboxConfig;
|
||||
}): Promise<SandboxWorkdir | null> {
|
||||
if (hasParentPathSegment(params.workdir)) {
|
||||
return null;
|
||||
}
|
||||
if (params.sandbox.workdirValidation === "backend") {
|
||||
return await resolveBackendValidatedSandboxWorkdir(params);
|
||||
}
|
||||
return await resolveHostValidatedSandboxWorkdir(params);
|
||||
}
|
||||
|
||||
export function formatUnavailableWorkdirFailure(workdir: string): string {
|
||||
return [
|
||||
`workdir "${workdir}" is unavailable or not a directory: command was not executed.`,
|
||||
'workdir is treated as a literal path; shell expansions such as "~" are not applied.',
|
||||
"Use an existing directory, omit an explicit workdir to use the default cwd, or update the configured default cwd.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export async function resolveExecWorkdir(params: {
|
||||
host: ExecHost;
|
||||
workdir?: string;
|
||||
defaultCwd?: string;
|
||||
sandbox?: BashSandboxConfig;
|
||||
}): Promise<ExecWorkdirResolution> {
|
||||
const explicitWorkdir = normalizeExplicitWorkdirInput(params.workdir);
|
||||
if (explicitWorkdir.kind === "blank") {
|
||||
return unavailable(explicitWorkdir.raw);
|
||||
}
|
||||
|
||||
if (params.host === "node") {
|
||||
return explicitWorkdir.kind === "specified"
|
||||
? { kind: "node", remoteCwd: explicitWorkdir.value }
|
||||
: { kind: "node" };
|
||||
}
|
||||
|
||||
const defaultCwd = normalizeOptionalString(params.defaultCwd);
|
||||
if (params.host === "sandbox") {
|
||||
const sandbox = params.sandbox;
|
||||
if (!sandbox) {
|
||||
throw new Error("exec internal error: sandbox workdir resolution requires sandbox config");
|
||||
}
|
||||
const requestedCwd =
|
||||
explicitWorkdir.kind === "specified"
|
||||
? explicitWorkdir.value
|
||||
: (defaultCwd ?? sandbox.containerWorkdir);
|
||||
const resolved = await resolveSandboxWorkdir({ workdir: requestedCwd, sandbox });
|
||||
return resolved
|
||||
? {
|
||||
kind: "sandbox",
|
||||
hostCwd: resolved.hostCwd,
|
||||
containerCwd: resolved.containerCwd,
|
||||
scriptPreflightCwd: resolved.scriptPreflightCwd,
|
||||
}
|
||||
: unavailable(requestedCwd);
|
||||
}
|
||||
|
||||
const requestedCwd =
|
||||
explicitWorkdir.kind === "specified" ? explicitWorkdir.value : (defaultCwd ?? safeCurrentCwd());
|
||||
if (!requestedCwd) {
|
||||
return unavailable("current working directory");
|
||||
}
|
||||
const resolved = resolveExistingHostWorkdir(requestedCwd);
|
||||
return resolved ? { kind: "local", hostCwd: resolved } : unavailable(requestedCwd);
|
||||
}
|
||||
@@ -216,29 +216,6 @@ describe("exec PATH login shell merge", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("fails without running when an explicit workdir is unavailable", async () => {
|
||||
const missingWorkdir = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-missing-workdir-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
fs.rmSync(missingWorkdir, { recursive: true, force: true });
|
||||
|
||||
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
|
||||
const result = await tool.execute("call-missing-workdir", {
|
||||
command: "echo ok",
|
||||
workdir: missingWorkdir,
|
||||
yieldMs: FOREGROUND_TEST_YIELD_MS,
|
||||
});
|
||||
const value = normalizeText(result.content.find((c) => c.type === "text")?.text);
|
||||
|
||||
expect(result.details?.status).toBe("failed");
|
||||
expect(value).toContain(`workdir "${missingWorkdir}" is unavailable or not a directory`);
|
||||
expect(value).toContain("command was not executed");
|
||||
expect(value).toContain("workdir is treated as a literal path");
|
||||
expect(value).toContain('shell expansions such as "~" are not applied');
|
||||
expect(value).not.toMatch(/^ok/);
|
||||
});
|
||||
|
||||
it("merges login-shell PATH for host=gateway", async () => {
|
||||
if (isWin) {
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
|
||||
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
|
||||
import type { ExtensionContext } from "./sessions/index.js";
|
||||
|
||||
declare module "../plugins/hook-types.js" {
|
||||
@@ -15,10 +14,6 @@ declare module "../plugins/hook-types.js" {
|
||||
}
|
||||
|
||||
const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT";
|
||||
type CapturedNodeHostParams = Pick<
|
||||
ExecuteNodeHostCommandParams,
|
||||
"env" | "requestedEnv" | "workdir"
|
||||
>;
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
hookRunner: undefined as
|
||||
@@ -33,7 +28,10 @@ const mocks = vi.hoisted(() => ({
|
||||
env: Record<string, string>;
|
||||
requestedEnv?: Record<string, string>;
|
||||
}>,
|
||||
nodeHostParams: [] as CapturedNodeHostParams[],
|
||||
nodeHostParams: [] as Array<{
|
||||
env: Record<string, string>;
|
||||
requestedEnv?: Record<string, string>;
|
||||
}>,
|
||||
spawnInputs: [] as Array<{
|
||||
env?: Record<string, string>;
|
||||
}>,
|
||||
@@ -66,11 +64,10 @@ vi.mock("./bash-tools.exec-host-gateway.js", () => ({
|
||||
|
||||
vi.mock("./bash-tools.exec-host-node.js", () => ({
|
||||
executeNodeHostCommand: vi.fn(
|
||||
async (params: Pick<ExecuteNodeHostCommandParams, "env" | "requestedEnv" | "workdir">) => {
|
||||
async (params: { env: Record<string, string>; requestedEnv?: Record<string, string> }) => {
|
||||
mocks.nodeHostParams.push({
|
||||
env: { ...params.env },
|
||||
requestedEnv: params.requestedEnv ? { ...params.requestedEnv } : undefined,
|
||||
workdir: params.workdir,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: "node ok" }],
|
||||
@@ -275,305 +272,6 @@ describe("exec resolve_exec_env hook wiring", () => {
|
||||
expect(mocks.nodeHostParams[0]?.env).not.toHaveProperty("LD_PRELOAD");
|
||||
});
|
||||
|
||||
it("does not forward configured gateway cwd defaults to node host requests", async () => {
|
||||
const tool = createExecTool({
|
||||
cwd: "/gateway/default/that/node/cannot/use",
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
|
||||
await tool.execute("call-node-default-cwd", {
|
||||
command: "echo ok",
|
||||
});
|
||||
|
||||
expect(mocks.nodeHostParams[0]?.workdir).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails blank explicit node host workdirs before node invocation", async () => {
|
||||
const tool = createExecTool({
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-node-blank-cwd", {
|
||||
command: "echo ok",
|
||||
workdir: " ",
|
||||
});
|
||||
const text = result.content.find((entry) => entry.type === "text")?.text ?? "";
|
||||
|
||||
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
||||
expect(text).toContain('workdir " " is unavailable or not a directory');
|
||||
expect(text).toContain("command was not executed");
|
||||
expect(mocks.nodeHostParams).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("prevalidates node workdirs before resolving exec env when a backend sandbox exists", async () => {
|
||||
installResolveExecEnvHook({ PLUGIN_SAFE: "yes" });
|
||||
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
||||
const tool = createExecTool({
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir: process.cwd(),
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-node-invalid-cwd-with-backend-sandbox", {
|
||||
command: "echo ok",
|
||||
workdir: " ",
|
||||
});
|
||||
|
||||
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
||||
expect(mocks.hookRunner?.runResolveExecEnv).not.toHaveBeenCalled();
|
||||
expect(validateWorkdir).not.toHaveBeenCalled();
|
||||
expect(mocks.nodeHostParams).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fails invalid workdirs before resolving exec env", async () => {
|
||||
installResolveExecEnvHook({ PLUGIN_SAFE: "yes" });
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-invalid-cwd-before-env", {
|
||||
command: "echo ok",
|
||||
workdir: " ",
|
||||
});
|
||||
|
||||
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
||||
expect(mocks.hookRunner?.runResolveExecEnv).not.toHaveBeenCalled();
|
||||
expect(mocks.gatewayParams).toHaveLength(0);
|
||||
expect(mocks.spawnInputs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("prevalidates gateway workdirs before resolving exec env when a backend sandbox exists", async () => {
|
||||
installResolveExecEnvHook({ PLUGIN_SAFE: "yes" });
|
||||
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir: process.cwd(),
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-gateway-invalid-cwd-with-backend-sandbox", {
|
||||
command: "echo ok",
|
||||
workdir: " ",
|
||||
});
|
||||
|
||||
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
||||
expect(mocks.hookRunner?.runResolveExecEnv).not.toHaveBeenCalled();
|
||||
expect(validateWorkdir).not.toHaveBeenCalled();
|
||||
expect(mocks.gatewayParams).toHaveLength(0);
|
||||
expect(mocks.spawnInputs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets before_tool_call see invalid wrapped workdirs before failing unchanged params", async () => {
|
||||
mocks.hookRunner = {
|
||||
hasHooks: vi.fn(
|
||||
(hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call",
|
||||
),
|
||||
runResolveExecEnv: vi.fn(async () => ({ PLUGIN_SAFE: "yes" })),
|
||||
runBeforeToolCall: vi.fn(async () => undefined),
|
||||
};
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
sessionKey: "agent:main:telegram:chat-1",
|
||||
});
|
||||
const [definition] = toToolDefinitions([tool], {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:chat-1",
|
||||
});
|
||||
|
||||
const result = await definition.execute(
|
||||
"call-invalid-wrapped-cwd-before-hooks",
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: " ",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
testExtensionContext,
|
||||
);
|
||||
const text = result.content.find((entry) => entry.type === "text")?.text ?? "";
|
||||
|
||||
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
||||
expect(text).toContain('workdir " " is unavailable or not a directory');
|
||||
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.hookRunner.runResolveExecEnv!).not.toHaveBeenCalled();
|
||||
expect(mocks.gatewayParams).toHaveLength(0);
|
||||
expect(mocks.spawnInputs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not validate backend sandbox workdirs before before_tool_call veto", async () => {
|
||||
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
|
||||
mocks.hookRunner = {
|
||||
hasHooks: vi.fn((hookName: string) => hookName === "before_tool_call"),
|
||||
runBeforeToolCall: vi.fn(async () => ({
|
||||
block: true,
|
||||
blockReason: "blocked by test hook",
|
||||
})),
|
||||
};
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir: process.cwd(),
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
},
|
||||
});
|
||||
const [definition] = toToolDefinitions([tool], {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:chat-1",
|
||||
});
|
||||
|
||||
const result = await definition.execute(
|
||||
"call-backend-cwd-vetoed-before-validation",
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: "/remote/workspace/generated",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
testExtensionContext,
|
||||
);
|
||||
|
||||
expect(
|
||||
result.details as { status?: unknown; deniedReason?: unknown } | undefined,
|
||||
).toMatchObject({
|
||||
status: "blocked",
|
||||
deniedReason: "plugin-before-tool-call",
|
||||
});
|
||||
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce();
|
||||
expect(validateWorkdir).not.toHaveBeenCalled();
|
||||
expect(mocks.gatewayParams).toHaveLength(0);
|
||||
expect(mocks.spawnInputs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("defers resolve_exec_env for backend sandboxes until workdir validation succeeds", async () => {
|
||||
const validateWorkdir = vi.fn(async () => null);
|
||||
mocks.hookRunner = {
|
||||
hasHooks: vi.fn(
|
||||
(hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call",
|
||||
),
|
||||
runResolveExecEnv: vi.fn(async () => ({ PLUGIN_SAFE: "yes" })),
|
||||
runBeforeToolCall: vi.fn(async () => undefined),
|
||||
};
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
sandbox: {
|
||||
containerName: "remote-sandbox-workdir-test",
|
||||
workspaceDir: process.cwd(),
|
||||
containerWorkdir: "/remote/workspace",
|
||||
workdirValidation: "backend",
|
||||
validateWorkdir,
|
||||
},
|
||||
});
|
||||
const [definition] = toToolDefinitions([tool], {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:chat-1",
|
||||
});
|
||||
|
||||
const result = await definition.execute(
|
||||
"call-backend-invalid-cwd-before-env",
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: "/remote/workspace/missing",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
testExtensionContext,
|
||||
);
|
||||
|
||||
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
||||
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce();
|
||||
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/missing");
|
||||
expect(mocks.hookRunner.runResolveExecEnv!).not.toHaveBeenCalled();
|
||||
expect(mocks.gatewayParams).toHaveLength(0);
|
||||
expect(mocks.spawnInputs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets lazy before_tool_call see invalid workdirs before failing unchanged params", async () => {
|
||||
mocks.hookRunner = {
|
||||
hasHooks: vi.fn(
|
||||
(hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call",
|
||||
),
|
||||
runResolveExecEnv: vi.fn(async () => ({ LAZY_PLUGIN_SAFE: "yes" })),
|
||||
runBeforeToolCall: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
const exec = createOpenClawCodingTools({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:chat-1",
|
||||
cwd: process.cwd(),
|
||||
exec: { host: "gateway", security: "full", ask: "off" },
|
||||
}).find((tool) => tool.name === "exec");
|
||||
expect(exec).toBeDefined();
|
||||
const [definition] = toToolDefinitions([exec!], {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:chat-1",
|
||||
channelId: "chat-1",
|
||||
});
|
||||
|
||||
const result = await definition.execute(
|
||||
"call-invalid-lazy-cwd-before-hooks",
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: " ",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
testExtensionContext,
|
||||
);
|
||||
const text = result.content.find((entry) => entry.type === "text")?.text ?? "";
|
||||
|
||||
expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed");
|
||||
expect(text).toContain('workdir " " is unavailable or not a directory');
|
||||
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.hookRunner.runResolveExecEnv!).not.toHaveBeenCalled();
|
||||
expect(mocks.gatewayParams).toHaveLength(0);
|
||||
expect(mocks.spawnInputs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("forwards explicit node host workdirs without local gateway validation", async () => {
|
||||
const remoteWorkdir = "/remote/node/workspace";
|
||||
const tool = createExecTool({
|
||||
host: "node",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
|
||||
await tool.execute("call-node-explicit-cwd", {
|
||||
command: "echo ok",
|
||||
workdir: remoteWorkdir,
|
||||
});
|
||||
|
||||
expect(mocks.nodeHostParams[0]?.workdir).toBe(remoteWorkdir);
|
||||
});
|
||||
|
||||
it("keeps plugin env out of before_tool_call params before execution", async () => {
|
||||
mocks.hookRunner = {
|
||||
hasHooks: vi.fn(
|
||||
@@ -724,57 +422,6 @@ describe("exec resolve_exec_env hook wiring", () => {
|
||||
expect(mocks.nodeHostParams[0]?.requestedEnv).not.toHaveProperty("GATEWAY_PLUGIN_SAFE");
|
||||
});
|
||||
|
||||
it("lets before_tool_call reroute gateway-invalid workdirs to node host execution", async () => {
|
||||
mocks.hookRunner = {
|
||||
hasHooks: vi.fn(
|
||||
(hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call",
|
||||
),
|
||||
runResolveExecEnv: vi.fn(async (event: { host: "gateway" | "sandbox" | "node" }) =>
|
||||
event.host === "node" ? { NODE_PLUGIN_SAFE: "node" } : { GATEWAY_PLUGIN_SAFE: "gateway" },
|
||||
),
|
||||
runBeforeToolCall: vi.fn(async (event: { params: Record<string, unknown> }) => ({
|
||||
params: { ...event.params, host: "node" },
|
||||
})),
|
||||
};
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "auto",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
sessionKey: "agent:main:telegram:chat-1",
|
||||
});
|
||||
const [definition] = toToolDefinitions([tool], {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:chat-1",
|
||||
});
|
||||
|
||||
await definition.execute(
|
||||
"call-host-rewrite-with-remote-cwd",
|
||||
{
|
||||
command: "echo ok",
|
||||
env: { REQUEST_SAFE: "request" },
|
||||
workdir: "/remote/node/workspace",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
testExtensionContext,
|
||||
);
|
||||
|
||||
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce();
|
||||
expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledOnce();
|
||||
expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ host: "node" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mocks.nodeHostParams[0]?.requestedEnv).toEqual({
|
||||
NODE_PLUGIN_SAFE: "node",
|
||||
REQUEST_SAFE: "request",
|
||||
});
|
||||
expect(mocks.nodeHostParams[0]?.workdir).toBe("/remote/node/workspace");
|
||||
expect(mocks.gatewayParams).toHaveLength(0);
|
||||
expect(mocks.spawnInputs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets before_tool_call rewrite host when no resolve_exec_env hook is registered", async () => {
|
||||
mocks.hookRunner = {
|
||||
hasHooks: vi.fn((hookName: string) => hookName === "before_tool_call"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user