Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
1
extensions/.npmignore
Normal file
1
extensions/.npmignore
Normal file
@@ -0,0 +1 @@
|
||||
**/node_modules/
|
||||
83
extensions/AGENTS.md
Normal file
83
extensions/AGENTS.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Extensions Boundary
|
||||
|
||||
This directory contains bundled plugins. Treat it as the same boundary that
|
||||
third-party plugins see.
|
||||
|
||||
## Public Contracts
|
||||
|
||||
- Docs:
|
||||
- `docs/plugins/building-plugins.md`
|
||||
- `docs/plugins/architecture.md`
|
||||
- `docs/plugins/sdk-overview.md`
|
||||
- `docs/plugins/sdk-entrypoints.md`
|
||||
- `docs/plugins/sdk-runtime.md`
|
||||
- `docs/plugins/sdk-channel-plugins.md`
|
||||
- `docs/plugins/sdk-provider-plugins.md`
|
||||
- `docs/plugins/manifest.md`
|
||||
- Definition files:
|
||||
- `src/plugin-sdk/plugin-entry.ts`
|
||||
- `src/plugin-sdk/core.ts`
|
||||
- `src/plugin-sdk/provider-entry.ts`
|
||||
- `src/plugin-sdk/channel-contract.ts`
|
||||
- `scripts/lib/plugin-sdk-entrypoints.json`
|
||||
- `package.json`
|
||||
|
||||
## Boundary Rules
|
||||
|
||||
- Extension production code should import from `openclaw/plugin-sdk/*` and its
|
||||
own local barrels such as `./api.ts` and `./runtime-api.ts`.
|
||||
- Do not import core internals from `src/**`, `src/channels/**`,
|
||||
`src/plugin-sdk-internal/**`, or another extension's `src/**`.
|
||||
- Do not use relative imports that escape the current extension package root.
|
||||
- Keep plugin metadata accurate in `openclaw.plugin.json` and the package
|
||||
`openclaw` block so discovery and setup work without executing plugin code.
|
||||
- Plugin runtime dependencies belong to the owning plugin package. If a plugin
|
||||
dependency has a runtime peer, declare/provide it in that plugin's
|
||||
`package.json`; do not move it to root unless root/package dist owns the
|
||||
import. Runtime never installs deps; install/update/doctor are repair points.
|
||||
- Keep plugin dependency assertions in generic contracts
|
||||
(`package-manifest.contract.test.ts`,
|
||||
`extension-runtime-dependencies.contract.test.ts`) rather than plugin e2e
|
||||
tests when they express package ownership.
|
||||
- Treat files like `src/**`, `onboard.ts`, and other local helpers as private
|
||||
unless you intentionally promote them through `api.ts` and, if needed, a
|
||||
matching `src/plugin-sdk/<id>.ts` facade.
|
||||
- If core or core tests need a bundled plugin helper, export it from `api.ts`
|
||||
first instead of letting them deep-import extension internals.
|
||||
- For provider plugins, keep auth, onboarding, catalog selection, and
|
||||
vendor-only product behavior local to the plugin. Do not move those into
|
||||
core just because two providers look similar.
|
||||
- Before adding a new provider-local `wrapStreamFn`, `buildReplayPolicy`,
|
||||
`normalizeToolSchemas`, `inspectToolSchemas`, or compat patch helper, check
|
||||
whether the same behavior already exists through `openclaw/plugin-sdk/*`.
|
||||
Reuse shared family helpers first.
|
||||
- If two bundled providers share the same replay policy shape, tool-schema
|
||||
compat rewrite, payload patch, or stream-wrapper chain, stop copying the
|
||||
logic. Extract one shared helper and migrate both call sites in the same
|
||||
change.
|
||||
- Prefer named provider-family helpers over repeating raw option bags. If a
|
||||
provider needs OpenAI-style Anthropic tool payload compat, Gemini schema
|
||||
cleanup, or an XAI compat patch, use a named shared helper instead of
|
||||
inlining the policy knobs again.
|
||||
- Keep control-plane metadata separate from runtime logic. Discovery, config
|
||||
validation, setup hints, onboarding hints, and activation planning should be
|
||||
expressible from manifest/descriptors whenever possible.
|
||||
- If setup truly requires runtime execution, make that explicit in the plugin's
|
||||
declared setup/runtime surface instead of letting metadata flows import
|
||||
runtime code accidentally.
|
||||
- Do not rely on eager global registry seeding or import-time side effects to
|
||||
make a plugin “available”. Plugin availability should come from manifest
|
||||
ownership plus targeted activation.
|
||||
- When core needs plugin-owned static data on a hot path, expose a lightweight
|
||||
top-level artifact such as `gateway-auth-api.ts`, `message-tool-api.ts`, or a
|
||||
similarly narrow `*-api.ts`. Reuse the same local helper from the artifact and
|
||||
the full plugin so fast paths do not drift from runtime behavior.
|
||||
|
||||
## Expanding The Boundary
|
||||
|
||||
- If an extension needs a new seam, add or replace a typed Plugin SDK subpath
|
||||
instead of reaching into core.
|
||||
- ALL bundled plugins must move to modern SDK seams in the same change. Do not
|
||||
keep extension-local compat paths for internal callers.
|
||||
- When intentionally expanding the contract, update the docs, exported subpath
|
||||
list, package exports, and API/contract checks in the same change.
|
||||
1
extensions/CLAUDE.md
Symbolic link
1
extensions/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
54
extensions/acpx/AGENTS.md
Normal file
54
extensions/acpx/AGENTS.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# ACPX Extension Notes
|
||||
|
||||
This file applies to work under `extensions/acpx/`.
|
||||
|
||||
## Purpose
|
||||
|
||||
The ACPX extension is a thin OpenClaw wrapper around the published `acpx` package. Keep reusable ACP runtime logic in `openclaw/acpx`, not in this extension.
|
||||
|
||||
## Default Version Policy
|
||||
|
||||
- `extensions/acpx/package.json` should point at a published npm release by default.
|
||||
- Do not leave the extension pinned to a temporary GitHub commit or local checkout once the ACPX release exists.
|
||||
- Do not leave temporary pnpm build-script allowlist exceptions behind after switching back to a published ACPX package.
|
||||
|
||||
## Unreleased ACPX Development Flow
|
||||
|
||||
Use this flow when OpenClaw needs unreleased ACPX changes before the ACPX version is published.
|
||||
|
||||
1. Make the ACPX code change in the `openclaw/acpx` repo first.
|
||||
2. In OpenClaw, temporarily point `extensions/acpx/package.json` at the ACPX GitHub commit you need.
|
||||
3. If pnpm blocks ACPX lifecycle/build scripts for that temporary GitHub-sourced package, temporarily add `acpx: true` to `allowBuilds` in `pnpm-workspace.yaml`.
|
||||
4. Refresh the root workspace lock:
|
||||
- `pnpm install --lockfile-only --filter ./extensions/acpx`
|
||||
5. Refresh the extension-local npm lock for install metadata:
|
||||
- `cd extensions/acpx && npm install --package-lock-only --ignore-scripts`
|
||||
6. Rebuild OpenClaw and restart the gateway before doing live ACP validation.
|
||||
7. Once ACPX is released, switch `extensions/acpx/package.json` back to the published npm version and refresh the same lockfiles again.
|
||||
8. Remove any temporary `acpx` build-script allowlist entry that was only needed for the GitHub-sourced development pin.
|
||||
|
||||
## Lockfile Notes
|
||||
|
||||
- `pnpm-lock.yaml` is the tracked workspace lockfile and must match the ACPX version referenced by `extensions/acpx/package.json`.
|
||||
- `extensions/acpx/package-lock.json` is useful local install metadata for the plugin package.
|
||||
- If `extensions/acpx/package-lock.json` is gitignored in this repo state, regenerating it is still useful for local verification, but it will not appear in `git status`.
|
||||
|
||||
## Local Runtime Validation
|
||||
|
||||
When ACPX integration changes here, prefer this sequence:
|
||||
|
||||
1. `pnpm install --filter ./extensions/acpx`
|
||||
2. `pnpm test:extension acpx`
|
||||
3. `pnpm build`
|
||||
4. Restart the local gateway if ACP runtime behavior or bundled plugin wiring changed.
|
||||
5. If the change affects direct ACP behavior in chat, run a real ACP smoke after restart.
|
||||
|
||||
## Direct ACPX Binary Policy
|
||||
|
||||
- Prefer the plugin-local ACPX binary under `extensions/acpx/node_modules/.bin/acpx`.
|
||||
- Do not rely on a globally installed `acpx` binary for OpenClaw ACP validation.
|
||||
- If the plugin-local ACPX binary is missing or on the wrong version, reinstall it from the version pinned in `extensions/acpx/package.json`.
|
||||
|
||||
## Boundary Rule
|
||||
|
||||
If a change feels like shared ACP runtime behavior instead of OpenClaw-specific glue, move it to `openclaw/acpx` and consume it from here instead of re-implementing it inside `extensions/acpx`.
|
||||
1
extensions/acpx/CLAUDE.md
Symbolic link
1
extensions/acpx/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
33
extensions/acpx/README.md
Normal file
33
extensions/acpx/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @openclaw/acpx
|
||||
|
||||
Official ACP runtime backend for OpenClaw.
|
||||
|
||||
ACPx lets OpenClaw run external coding harnesses through the Agent Client Protocol while OpenClaw still owns sessions, channels, delivery, permissions, and Gateway state.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/acpx
|
||||
```
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
## What it provides
|
||||
|
||||
- ACP-backed agent runtime sessions.
|
||||
- Plugin-owned session and transport management.
|
||||
- MCP bridge helpers for OpenClaw tools and plugin tools.
|
||||
- Static runtime assets used by the ACP process bridge.
|
||||
|
||||
## Configure
|
||||
|
||||
Use the ACP docs for harness-specific setup, permission modes, and model/runtime selection:
|
||||
|
||||
- https://docs.openclaw.ai/tools/acp-agents-setup
|
||||
- https://docs.openclaw.ai/tools/acp-agents
|
||||
|
||||
## Package
|
||||
|
||||
- Plugin id: `acpx`
|
||||
- Package: `@openclaw/acpx`
|
||||
- Minimum OpenClaw host: `2026.4.25`
|
||||
280
extensions/acpx/doctor-contract-api.test.ts
Normal file
280
extensions/acpx/doctor-contract-api.test.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
// ACPX tests cover doctor migration of legacy runtime state.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginDoctorStateMigrationContext,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { stateMigrations } from "./doctor-contract-api.js";
|
||||
import { openAcpxProcessLeaseStateStore, type AcpxProcessLease } from "./src/process-lease.js";
|
||||
import {
|
||||
ACPX_GATEWAY_INSTANCE_KEY,
|
||||
ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
ACPX_LEGACY_GATEWAY_INSTANCE_FILE,
|
||||
ACPX_LEGACY_PROCESS_LEASE_FILE,
|
||||
type AcpxGatewayInstanceRecord,
|
||||
} from "./src/state.js";
|
||||
|
||||
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
|
||||
return {
|
||||
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
|
||||
return createPluginStateKeyedStoreForTests<T>("acpx", {
|
||||
...options,
|
||||
env: options.env ?? env,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("acpx doctor state migration", () => {
|
||||
let stateDir = "";
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetPluginStateStoreForTests();
|
||||
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-doctor-"));
|
||||
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function migrationParams() {
|
||||
return {
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
};
|
||||
}
|
||||
|
||||
it("imports legacy gateway identity and open process leases into plugin state", async () => {
|
||||
const gatewayPath = path.join(stateDir, ACPX_LEGACY_GATEWAY_INSTANCE_FILE);
|
||||
const leasePath = path.join(stateDir, "acpx", ACPX_LEGACY_PROCESS_LEASE_FILE);
|
||||
const lease: AcpxProcessLease = {
|
||||
leaseId: "lease-1",
|
||||
gatewayInstanceId: "gw-test",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
wrapperRoot: path.join(stateDir, "acpx"),
|
||||
wrapperPath: path.join(stateDir, "acpx", "codex-acp-wrapper.mjs"),
|
||||
rootPid: 101,
|
||||
commandHash: "hash",
|
||||
startedAt: 1,
|
||||
state: "open",
|
||||
};
|
||||
await fs.mkdir(path.dirname(leasePath), { recursive: true });
|
||||
await fs.writeFile(gatewayPath, "gw-test\n", "utf8");
|
||||
await fs.writeFile(
|
||||
leasePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
leases: [
|
||||
lease,
|
||||
{
|
||||
...lease,
|
||||
leaseId: "closed-lease",
|
||||
state: "closed",
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const migration = stateMigrations[0];
|
||||
await expect(migration.detectLegacyState(migrationParams())).resolves.toMatchObject({
|
||||
preview: [
|
||||
expect.stringContaining("ACPX gateway instance id"),
|
||||
expect.stringContaining("1 open lease"),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await migration.migrateLegacyState(migrationParams());
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toEqual([
|
||||
"Migrated ACPX gateway instance id -> plugin state",
|
||||
expect.stringContaining("Archived ACPX gateway-instance-id legacy source"),
|
||||
"Migrated ACPX process leases -> plugin state (1 imported, 0 already present)",
|
||||
expect.stringContaining("Archived ACPX process-leases legacy source"),
|
||||
]);
|
||||
await expect(fs.access(gatewayPath)).rejects.toThrow();
|
||||
await expect(fs.access(`${gatewayPath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(fs.access(leasePath)).rejects.toThrow();
|
||||
await expect(fs.access(`${leasePath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
createDoctorContext(env)
|
||||
.openPluginStateKeyedStore<AcpxGatewayInstanceRecord>({
|
||||
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
maxEntries: ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
})
|
||||
.lookup(ACPX_GATEWAY_INSTANCE_KEY),
|
||||
).resolves.toMatchObject({ instanceId: "gw-test" });
|
||||
await expect(
|
||||
openAcpxProcessLeaseStateStore(createDoctorContext(env).openPluginStateKeyedStore).lookup(
|
||||
"lease-1",
|
||||
),
|
||||
).resolves.toEqual(lease);
|
||||
await expect(
|
||||
openAcpxProcessLeaseStateStore(createDoctorContext(env).openPluginStateKeyedStore).lookup(
|
||||
"closed-lease",
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores legacy process lease files without open cleanup work", async () => {
|
||||
const leasePath = path.join(stateDir, "acpx", ACPX_LEGACY_PROCESS_LEASE_FILE);
|
||||
await fs.mkdir(path.dirname(leasePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
leasePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
leases: [
|
||||
{
|
||||
leaseId: "closed-lease",
|
||||
gatewayInstanceId: "gw-test",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
wrapperRoot: path.join(stateDir, "acpx"),
|
||||
wrapperPath: path.join(stateDir, "acpx", "codex-acp-wrapper.mjs"),
|
||||
rootPid: 101,
|
||||
commandHash: "hash",
|
||||
startedAt: 1,
|
||||
state: "closed",
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const migration = stateMigrations[0];
|
||||
|
||||
await expect(migration.detectLegacyState(migrationParams())).resolves.toBeNull();
|
||||
await expect(migration.migrateLegacyState(migrationParams())).resolves.toEqual({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
});
|
||||
await expect(fs.access(leasePath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves legacy leases in place when the canonical gateway id would not reap them", async () => {
|
||||
const gatewayPath = path.join(stateDir, ACPX_LEGACY_GATEWAY_INSTANCE_FILE);
|
||||
const leasePath = path.join(stateDir, "acpx", ACPX_LEGACY_PROCESS_LEASE_FILE);
|
||||
await fs.mkdir(path.dirname(leasePath), { recursive: true });
|
||||
await fs.writeFile(gatewayPath, "legacy-gw\n", "utf8");
|
||||
await fs.writeFile(
|
||||
leasePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
leases: [
|
||||
{
|
||||
leaseId: "lease-1",
|
||||
gatewayInstanceId: "legacy-gw",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
wrapperRoot: path.join(stateDir, "acpx"),
|
||||
wrapperPath: path.join(stateDir, "acpx", "codex-acp-wrapper.mjs"),
|
||||
rootPid: 101,
|
||||
commandHash: "hash",
|
||||
startedAt: 1,
|
||||
state: "open",
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await createDoctorContext(env)
|
||||
.openPluginStateKeyedStore<AcpxGatewayInstanceRecord>({
|
||||
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
maxEntries: ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
})
|
||||
.register(ACPX_GATEWAY_INSTANCE_KEY, {
|
||||
instanceId: "current-gw",
|
||||
createdAt: 2,
|
||||
});
|
||||
await openAcpxProcessLeaseStateStore(
|
||||
createDoctorContext(env).openPluginStateKeyedStore,
|
||||
).register("current-lease", {
|
||||
leaseId: "current-lease",
|
||||
gatewayInstanceId: "current-gw",
|
||||
sessionKey: "agent:codex:acp:current",
|
||||
wrapperRoot: path.join(stateDir, "acpx"),
|
||||
wrapperPath: path.join(stateDir, "acpx", "codex-acp-wrapper.mjs"),
|
||||
rootPid: 202,
|
||||
commandHash: "hash-current",
|
||||
startedAt: 2,
|
||||
state: "open",
|
||||
});
|
||||
|
||||
const result = await stateMigrations[0].migrateLegacyState(migrationParams());
|
||||
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.warnings).toEqual([
|
||||
"Skipped ACPX process lease migration because legacy leases do not match the canonical gateway instance id; left legacy sources in place for manual cleanup",
|
||||
]);
|
||||
await expect(fs.access(gatewayPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(leasePath)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
openAcpxProcessLeaseStateStore(createDoctorContext(env).openPluginStateKeyedStore).lookup(
|
||||
"lease-1",
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("adopts the legacy gateway id when upgraded startup created only an empty sqlite id", async () => {
|
||||
const gatewayPath = path.join(stateDir, ACPX_LEGACY_GATEWAY_INSTANCE_FILE);
|
||||
const leasePath = path.join(stateDir, "acpx", ACPX_LEGACY_PROCESS_LEASE_FILE);
|
||||
const legacyLease: AcpxProcessLease = {
|
||||
leaseId: "legacy-lease",
|
||||
gatewayInstanceId: "legacy-gw",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
wrapperRoot: path.join(stateDir, "acpx"),
|
||||
wrapperPath: path.join(stateDir, "acpx", "codex-acp-wrapper.mjs"),
|
||||
rootPid: 101,
|
||||
commandHash: "hash",
|
||||
startedAt: 1,
|
||||
state: "open",
|
||||
};
|
||||
await fs.mkdir(path.dirname(leasePath), { recursive: true });
|
||||
await fs.writeFile(gatewayPath, "legacy-gw\n", "utf8");
|
||||
await fs.writeFile(leasePath, JSON.stringify({ version: 1, leases: [legacyLease] }), "utf8");
|
||||
await createDoctorContext(env)
|
||||
.openPluginStateKeyedStore<AcpxGatewayInstanceRecord>({
|
||||
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
maxEntries: ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
})
|
||||
.register(ACPX_GATEWAY_INSTANCE_KEY, {
|
||||
instanceId: "fresh-empty-gw",
|
||||
createdAt: 2,
|
||||
});
|
||||
|
||||
const result = await stateMigrations[0].migrateLegacyState(migrationParams());
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toEqual([
|
||||
"Migrated ACPX gateway instance id -> plugin state",
|
||||
expect.stringContaining("Archived ACPX gateway-instance-id legacy source"),
|
||||
"Migrated ACPX process leases -> plugin state (1 imported, 0 already present)",
|
||||
expect.stringContaining("Archived ACPX process-leases legacy source"),
|
||||
]);
|
||||
await expect(
|
||||
createDoctorContext(env)
|
||||
.openPluginStateKeyedStore<AcpxGatewayInstanceRecord>({
|
||||
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
maxEntries: ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
})
|
||||
.lookup(ACPX_GATEWAY_INSTANCE_KEY),
|
||||
).resolves.toMatchObject({ instanceId: "legacy-gw" });
|
||||
await expect(
|
||||
openAcpxProcessLeaseStateStore(createDoctorContext(env).openPluginStateKeyedStore).lookup(
|
||||
"legacy-lease",
|
||||
),
|
||||
).resolves.toEqual(legacyLease);
|
||||
});
|
||||
});
|
||||
180
extensions/acpx/doctor-contract-api.ts
Normal file
180
extensions/acpx/doctor-contract-api.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// ACPX doctor contract migrates shipped plugin-owned runtime state.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
archiveLegacyStateSource,
|
||||
type PluginDoctorStateMigration,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import {
|
||||
normalizeAcpxProcessLease,
|
||||
normalizeAcpxProcessLeaseFile,
|
||||
openAcpxProcessLeaseStateStore,
|
||||
type AcpxProcessLease,
|
||||
} from "./src/process-lease.js";
|
||||
import {
|
||||
ACPX_GATEWAY_INSTANCE_KEY,
|
||||
ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
ACPX_LEGACY_GATEWAY_INSTANCE_FILE,
|
||||
ACPX_LEGACY_PROCESS_LEASE_FILE,
|
||||
normalizeAcpxGatewayInstanceRecord,
|
||||
type AcpxGatewayInstanceRecord,
|
||||
} from "./src/state.js";
|
||||
|
||||
function resolveLegacyGatewayInstancePath(stateDir: string): string {
|
||||
return path.join(stateDir, ACPX_LEGACY_GATEWAY_INSTANCE_FILE);
|
||||
}
|
||||
|
||||
function resolveLegacyProcessLeasePath(stateDir: string): string {
|
||||
return path.join(stateDir, "acpx", ACPX_LEGACY_PROCESS_LEASE_FILE);
|
||||
}
|
||||
|
||||
async function readLegacyGatewayInstanceId(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const value = (await fs.readFile(filePath, "utf8")).trim();
|
||||
return value || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLegacyOpenProcessLeases(filePath: string): Promise<AcpxProcessLease[]> {
|
||||
try {
|
||||
const leaseFile = normalizeAcpxProcessLeaseFile(
|
||||
JSON.parse(await fs.readFile(filePath, "utf8")),
|
||||
);
|
||||
return leaseFile.leases.filter((lease) => lease.state === "open" || lease.state === "closing");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const stateMigrations: PluginDoctorStateMigration[] = [
|
||||
{
|
||||
id: "acpx-runtime-state-to-plugin-state",
|
||||
label: "ACPX runtime state",
|
||||
async detectLegacyState(params) {
|
||||
const gatewayInstanceId = await readLegacyGatewayInstanceId(
|
||||
resolveLegacyGatewayInstancePath(params.stateDir),
|
||||
);
|
||||
const openLeases = await readLegacyOpenProcessLeases(
|
||||
resolveLegacyProcessLeasePath(params.stateDir),
|
||||
);
|
||||
if (!gatewayInstanceId && openLeases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const preview: string[] = [];
|
||||
if (gatewayInstanceId) {
|
||||
preview.push(
|
||||
`- ACPX gateway instance id: ${resolveLegacyGatewayInstancePath(params.stateDir)} -> plugin state (${ACPX_GATEWAY_INSTANCE_NAMESPACE})`,
|
||||
);
|
||||
}
|
||||
if (openLeases.length > 0) {
|
||||
preview.push(
|
||||
`- ACPX process leases: ${resolveLegacyProcessLeasePath(params.stateDir)} -> plugin state (${openLeases.length} open lease(s))`,
|
||||
);
|
||||
}
|
||||
return { preview };
|
||||
},
|
||||
async migrateLegacyState(params) {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const gatewayInstancePath = resolveLegacyGatewayInstancePath(params.stateDir);
|
||||
const gatewayInstanceId = await readLegacyGatewayInstanceId(gatewayInstancePath);
|
||||
const processLeasePath = resolveLegacyProcessLeasePath(params.stateDir);
|
||||
const openLeases = await readLegacyOpenProcessLeases(processLeasePath);
|
||||
const processLeaseStore = openAcpxProcessLeaseStateStore(
|
||||
params.context.openPluginStateKeyedStore,
|
||||
);
|
||||
const gatewayStore = params.context.openPluginStateKeyedStore<AcpxGatewayInstanceRecord>({
|
||||
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
maxEntries: ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
});
|
||||
const existingGateway = normalizeAcpxGatewayInstanceRecord(
|
||||
await gatewayStore.lookup(ACPX_GATEWAY_INSTANCE_KEY),
|
||||
);
|
||||
const existingLiveLeases = (await processLeaseStore.entries())
|
||||
.map((entry) => normalizeAcpxProcessLease(entry.value))
|
||||
.filter(
|
||||
(lease): lease is AcpxProcessLease =>
|
||||
lease != null && (lease.state === "open" || lease.state === "closing"),
|
||||
);
|
||||
const leaseGatewayIds = new Set(openLeases.map((lease) => lease.gatewayInstanceId));
|
||||
const onlyLeaseGatewayId = leaseGatewayIds.size === 1 ? [...leaseGatewayIds][0] : null;
|
||||
const canAdoptLegacyGateway =
|
||||
existingGateway &&
|
||||
gatewayInstanceId &&
|
||||
existingGateway.instanceId !== gatewayInstanceId &&
|
||||
onlyLeaseGatewayId === gatewayInstanceId &&
|
||||
existingLiveLeases.length === 0;
|
||||
const canonicalGatewayInstanceId =
|
||||
canAdoptLegacyGateway || !existingGateway
|
||||
? (gatewayInstanceId ?? onlyLeaseGatewayId)
|
||||
: existingGateway.instanceId;
|
||||
|
||||
if (
|
||||
openLeases.length > 0 &&
|
||||
(!canonicalGatewayInstanceId ||
|
||||
[...leaseGatewayIds].some(
|
||||
(leaseGatewayId) => leaseGatewayId !== canonicalGatewayInstanceId,
|
||||
))
|
||||
) {
|
||||
warnings.push(
|
||||
"Skipped ACPX process lease migration because legacy leases do not match the canonical gateway instance id; left legacy sources in place for manual cleanup",
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
if (canAdoptLegacyGateway && canonicalGatewayInstanceId) {
|
||||
await gatewayStore.register(ACPX_GATEWAY_INSTANCE_KEY, {
|
||||
instanceId: canonicalGatewayInstanceId,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
changes.push("Migrated ACPX gateway instance id -> plugin state");
|
||||
} else if (canonicalGatewayInstanceId && !existingGateway) {
|
||||
await gatewayStore.register(ACPX_GATEWAY_INSTANCE_KEY, {
|
||||
instanceId: canonicalGatewayInstanceId,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
changes.push("Migrated ACPX gateway instance id -> plugin state");
|
||||
} else if (gatewayInstanceId && existingGateway?.instanceId !== gatewayInstanceId) {
|
||||
warnings.push(
|
||||
"Skipped ACPX gateway instance id import because plugin state already differs",
|
||||
);
|
||||
}
|
||||
|
||||
if (gatewayInstanceId) {
|
||||
await archiveLegacyStateSource({
|
||||
filePath: gatewayInstancePath,
|
||||
label: "ACPX gateway-instance-id",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
if (openLeases.length > 0) {
|
||||
let imported = 0;
|
||||
let alreadyPresent = 0;
|
||||
for (const lease of openLeases) {
|
||||
const inserted = await processLeaseStore.registerIfAbsent(lease.leaseId, lease);
|
||||
if (inserted) {
|
||||
imported++;
|
||||
} else {
|
||||
alreadyPresent++;
|
||||
}
|
||||
}
|
||||
changes.push(
|
||||
`Migrated ACPX process leases -> plugin state (${imported} imported, ${alreadyPresent} already present)`,
|
||||
);
|
||||
await archiveLegacyStateSource({
|
||||
filePath: processLeasePath,
|
||||
label: "ACPX process-leases",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
return { changes, warnings };
|
||||
},
|
||||
},
|
||||
];
|
||||
145
extensions/acpx/index.test.ts
Normal file
145
extensions/acpx/index.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// ACPX tests cover index plugin behavior.
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import setupPlugin from "./setup-api.js";
|
||||
|
||||
const { createAcpxRuntimeServiceMock, tryDispatchAcpReplyHookMock } = vi.hoisted(() => ({
|
||||
createAcpxRuntimeServiceMock: vi.fn(),
|
||||
tryDispatchAcpReplyHookMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./register.runtime.js", () => ({
|
||||
createAcpxRuntimeService: createAcpxRuntimeServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/acp-runtime-backend", () => ({
|
||||
tryDispatchAcpReplyHook: tryDispatchAcpReplyHookMock,
|
||||
}));
|
||||
|
||||
import plugin from "./index.js";
|
||||
|
||||
type AcpxAutoEnableProbe = Parameters<OpenClawPluginApi["registerAutoEnableProbe"]>[0];
|
||||
|
||||
function registerAcpxAutoEnableProbe(): AcpxAutoEnableProbe {
|
||||
const probes: AcpxAutoEnableProbe[] = [];
|
||||
setupPlugin.register(
|
||||
createTestPluginApi({
|
||||
registerAutoEnableProbe(probe) {
|
||||
probes.push(probe);
|
||||
},
|
||||
}),
|
||||
);
|
||||
const probe = probes[0];
|
||||
if (!probe) {
|
||||
throw new Error("expected ACPX setup plugin to register an auto-enable probe");
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
describe("acpx plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("registers the runtime service and reply_dispatch hook", () => {
|
||||
const service = { id: "acpx-service", start: vi.fn() };
|
||||
createAcpxRuntimeServiceMock.mockReturnValue(service);
|
||||
const openKeyedStore = vi.fn();
|
||||
|
||||
const api = {
|
||||
pluginConfig: { stateDir: "/tmp/acpx" },
|
||||
runtime: { state: { openKeyedStore } },
|
||||
registerService: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
plugin.register(api as never);
|
||||
|
||||
expect(createAcpxRuntimeServiceMock).toHaveBeenCalledWith({
|
||||
pluginConfig: api.pluginConfig,
|
||||
openKeyedStore: expect.any(Function),
|
||||
});
|
||||
const params = createAcpxRuntimeServiceMock.mock.calls[0]?.[0] as {
|
||||
openKeyedStore: typeof openKeyedStore;
|
||||
};
|
||||
params.openKeyedStore({ namespace: "test", maxEntries: 1 });
|
||||
expect(openKeyedStore).toHaveBeenCalledWith({ namespace: "test", maxEntries: 1 });
|
||||
expect(api.registerService).toHaveBeenCalledWith(service);
|
||||
expect(api.on).toHaveBeenCalledWith("reply_dispatch", tryDispatchAcpReplyHookMock);
|
||||
});
|
||||
|
||||
it("does not touch runtime state while registering metadata-only plugin APIs", () => {
|
||||
const service = { id: "acpx-service", start: vi.fn() };
|
||||
createAcpxRuntimeServiceMock.mockReturnValue(service);
|
||||
|
||||
const api = {
|
||||
pluginConfig: {},
|
||||
runtime: {},
|
||||
registerService: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
expect(() => plugin.register(api as never)).not.toThrow();
|
||||
expect(api.registerService).toHaveBeenCalledWith(service);
|
||||
});
|
||||
|
||||
it("preserves the ACP reply_dispatch runtime path through the registered hook", async () => {
|
||||
const service = { id: "acpx-service", start: vi.fn() };
|
||||
createAcpxRuntimeServiceMock.mockReturnValue(service);
|
||||
tryDispatchAcpReplyHookMock.mockResolvedValue({
|
||||
handled: true,
|
||||
queuedFinal: true,
|
||||
counts: { tool: 1, block: 0, final: 1 },
|
||||
});
|
||||
|
||||
const on = vi.fn();
|
||||
const openKeyedStore = vi.fn();
|
||||
const api = createTestPluginApi({
|
||||
pluginConfig: { stateDir: "/tmp/acpx" },
|
||||
runtime: { state: { openKeyedStore } } as never,
|
||||
registerService: vi.fn(),
|
||||
on,
|
||||
});
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
const hook = on.mock.calls.find(([hookName]) => hookName === "reply_dispatch")?.[1];
|
||||
if (!hook) {
|
||||
throw new Error("expected reply_dispatch hook to be registered");
|
||||
}
|
||||
|
||||
const event = {
|
||||
ctx: { raw: "reply ctx" },
|
||||
runId: "run-1",
|
||||
sessionKey: "agent:test:session",
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
shouldSendToolSummaries: true,
|
||||
sendPolicy: "allow",
|
||||
};
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
dispatcher: { dispatch: vi.fn(), getQueuedCounts: vi.fn(), getFailedCounts: vi.fn() },
|
||||
recordProcessed: vi.fn(),
|
||||
markIdle: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(hook(event, ctx)).resolves.toEqual({
|
||||
handled: true,
|
||||
queuedFinal: true,
|
||||
counts: { tool: 1, block: 0, final: 1 },
|
||||
});
|
||||
expect(tryDispatchAcpReplyHookMock).toHaveBeenCalledWith(event, ctx);
|
||||
});
|
||||
|
||||
it("declares setup auto-enable reasons for ACPX-owned ACP config", () => {
|
||||
const probe = registerAcpxAutoEnableProbe();
|
||||
|
||||
expect(probe({ config: { acp: { enabled: true } }, env: {} })).toBe("ACP runtime configured");
|
||||
expect(probe({ config: { acp: { backend: "acpx" } }, env: {} })).toBe("ACP runtime configured");
|
||||
expect(probe({ config: { acp: { enabled: true, backend: "custom-runtime" } }, env: {} })).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
24
extensions/acpx/index.ts
Normal file
24
extensions/acpx/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* ACPX runtime plugin entry. It registers the embedded ACP backend service and
|
||||
* wires reply-dispatch hooks into the plugin SDK runtime.
|
||||
*/
|
||||
import { tryDispatchAcpReplyHook } from "openclaw/plugin-sdk/acp-runtime-backend";
|
||||
import { createAcpxRuntimeService } from "./register.runtime.js";
|
||||
import type { OpenClawPluginApi } from "./runtime-api.js";
|
||||
|
||||
const plugin = {
|
||||
id: "acpx",
|
||||
name: "ACPX Runtime",
|
||||
description: "Embedded ACP runtime backend with plugin-owned session and transport management.",
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerService(
|
||||
createAcpxRuntimeService({
|
||||
pluginConfig: api.pluginConfig,
|
||||
openKeyedStore: (options) => api.runtime.state.openKeyedStore(options),
|
||||
}),
|
||||
);
|
||||
api.on("reply_dispatch", tryDispatchAcpReplyHook);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
2283
extensions/acpx/npm-shrinkwrap.json
generated
Normal file
2283
extensions/acpx/npm-shrinkwrap.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
174
extensions/acpx/openclaw.plugin.json
Normal file
174
extensions/acpx/openclaw.plugin.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"id": "acpx",
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"name": "ACPX Runtime",
|
||||
"description": "OpenClaw ACP runtime backend with plugin-owned session and transport management.",
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"stateDir": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"probeAgent": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"permissionMode": {
|
||||
"type": "string",
|
||||
"enum": ["approve-all", "approve-reads", "deny-all"]
|
||||
},
|
||||
"nonInteractivePermissions": {
|
||||
"type": "string",
|
||||
"enum": ["deny", "fail"]
|
||||
},
|
||||
"pluginToolsMcpBridge": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"openClawToolsMcpBridge": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"strictWindowsCmdWrapper": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 0.001,
|
||||
"default": 120
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"probeAgent": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"mcpServers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Command to run the MCP server"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Arguments to pass to the command"
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Environment variables for the MCP server"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"cwd": {
|
||||
"label": "Default Working Directory",
|
||||
"help": "Default working directory for embedded ACP session operations when not set per session."
|
||||
},
|
||||
"stateDir": {
|
||||
"label": "State Directory",
|
||||
"help": "Directory used for embedded ACP session state and persistence."
|
||||
},
|
||||
"permissionMode": {
|
||||
"label": "Permission Mode",
|
||||
"help": "Default permission policy for embedded ACP runtime prompts."
|
||||
},
|
||||
"nonInteractivePermissions": {
|
||||
"label": "Non-Interactive Permission Policy",
|
||||
"help": "Policy when interactive permission prompts are unavailable."
|
||||
},
|
||||
"pluginToolsMcpBridge": {
|
||||
"label": "Plugin Tools MCP Bridge",
|
||||
"help": "Default off. When enabled, inject the built-in OpenClaw plugin-tools MCP server into embedded ACP sessions so ACP agents can call plugin-registered tools.",
|
||||
"advanced": true
|
||||
},
|
||||
"openClawToolsMcpBridge": {
|
||||
"label": "OpenClaw Tools MCP Bridge",
|
||||
"help": "Default off. When enabled, inject the built-in OpenClaw core-tools MCP server into embedded ACP sessions so ACP agents can call selected built-in tools such as cron.",
|
||||
"advanced": true
|
||||
},
|
||||
"strictWindowsCmdWrapper": {
|
||||
"label": "Strict Windows cmd Wrapper",
|
||||
"help": "Legacy compatibility field. The current embedded acpx/runtime package uses its own Windows command resolution behavior. Setting this to false is accepted for compatibility and logged as ignored.",
|
||||
"advanced": true
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"label": "Runtime Operation Timeout Seconds",
|
||||
"help": "Timeout for embedded ACP runtime startup and control operations. ACP turns use OpenClaw agent/run timeouts.",
|
||||
"advanced": true
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"label": "Queue Owner TTL Seconds",
|
||||
"help": "Reserved compatibility field for the older embedded ACPX queue-owner path. Accepted for compatibility and logged as ignored.",
|
||||
"advanced": true
|
||||
},
|
||||
"probeAgent": {
|
||||
"label": "Health Probe Agent",
|
||||
"help": "Agent id used for the embedded ACP runtime health probe. Defaults to the first `acp.allowedAgents` entry when that allowlist is set, otherwise to the runtime built-in probe agent (codex). Set this explicitly (for example `opencode` or `claude`) when the default probe agent is not installed or not authenticated, so the whole embedded ACP backend does not get marked unavailable.",
|
||||
"advanced": true
|
||||
},
|
||||
"mcpServers": {
|
||||
"label": "MCP Servers",
|
||||
"help": "Named MCP server definitions to inject into embedded ACP session bootstrap. Each entry needs a command and can include args and env.",
|
||||
"advanced": true
|
||||
},
|
||||
"agents": {
|
||||
"label": "Agent Commands",
|
||||
"help": "Optional per-agent command overrides for the embedded ACP runtime.",
|
||||
"advanced": true
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"dangerousFlags": [
|
||||
{
|
||||
"path": "permissionMode",
|
||||
"equals": "approve-all"
|
||||
}
|
||||
],
|
||||
"secretInputs": {
|
||||
"bundledDefaultEnabled": false,
|
||||
"paths": [
|
||||
{
|
||||
"path": "mcpServers.*.env.*",
|
||||
"expected": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
50
extensions/acpx/package.json
Normal file
50
extensions/acpx/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw ACP runtime backend with plugin-owned session and transport management.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.55.0",
|
||||
"@zed-industries/codex-acp": "0.16.0",
|
||||
"acpx": "0.11.2",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/acpx",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-proxy.mjs",
|
||||
"output": "mcp-proxy.mjs"
|
||||
},
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-command-line.mjs",
|
||||
"output": "mcp-command-line.mjs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true,
|
||||
"bundleRuntimeDependencies": false
|
||||
}
|
||||
}
|
||||
}
|
||||
168
extensions/acpx/register.runtime.test.ts
Normal file
168
extensions/acpx/register.runtime.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// ACPX tests cover register plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { runtimeRegistry } = vi.hoisted(() => ({
|
||||
runtimeRegistry: new Map<string, { runtime: unknown }>(),
|
||||
}));
|
||||
|
||||
const { realRuntime, realServiceStartMock, realServiceStopMock, createRealServiceMock } =
|
||||
vi.hoisted(() => {
|
||||
const runtime = {
|
||||
async ensureSession(input: { sessionKey: string }) {
|
||||
return {
|
||||
backend: "acpx",
|
||||
runtimeSessionName: input.sessionKey,
|
||||
sessionKey: input.sessionKey,
|
||||
};
|
||||
},
|
||||
async *runTurn() {},
|
||||
async cancel() {},
|
||||
async close() {},
|
||||
isHealthy: vi.fn(() => true),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
};
|
||||
const start = vi.fn(async () => {
|
||||
runtimeRegistry.set("acpx", { runtime });
|
||||
});
|
||||
const stop = vi.fn(async () => {
|
||||
runtimeRegistry.delete("acpx");
|
||||
});
|
||||
return {
|
||||
realRuntime: runtime,
|
||||
realServiceStartMock: start,
|
||||
realServiceStopMock: stop,
|
||||
createRealServiceMock: vi.fn(() => ({ id: "real-acpx-runtime", start, stop })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/acp-runtime-backend", () => ({
|
||||
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
|
||||
registerAcpRuntimeBackend: (entry: { id: string; runtime: unknown }) => {
|
||||
runtimeRegistry.set(entry.id, entry);
|
||||
},
|
||||
unregisterAcpRuntimeBackend: (id: string) => {
|
||||
runtimeRegistry.delete(id);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./src/service.js", () => ({
|
||||
createAcpxRuntimeService: createRealServiceMock,
|
||||
}));
|
||||
|
||||
import { createAcpxRuntimeService } from "./register.runtime.js";
|
||||
|
||||
const previousSkipRuntime = process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
|
||||
|
||||
function restoreEnv(): void {
|
||||
if (previousSkipRuntime === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = previousSkipRuntime;
|
||||
}
|
||||
}
|
||||
|
||||
function createServiceContext() {
|
||||
return {
|
||||
workspaceDir: "/tmp/openclaw-acpx-register-test",
|
||||
stateDir: "/tmp/openclaw-acpx-register-test/state",
|
||||
config: {},
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("acpx register runtime service", () => {
|
||||
afterEach(() => {
|
||||
runtimeRegistry.clear();
|
||||
realServiceStartMock.mockClear();
|
||||
realServiceStopMock.mockClear();
|
||||
createRealServiceMock.mockClear();
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
it("registers the acpx backend at startup and starts the real service on first use", async () => {
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
|
||||
const ctx = createServiceContext();
|
||||
const service = createAcpxRuntimeService({
|
||||
pluginConfig: { timeoutSeconds: 10 },
|
||||
});
|
||||
|
||||
await service.start(ctx as never);
|
||||
|
||||
const deferredRuntime = runtimeRegistry.get("acpx")?.runtime as {
|
||||
ensureSession(input: { sessionKey: string; agent: string; mode: string }): Promise<unknown>;
|
||||
startTurn(input: {
|
||||
handle: { sessionKey: string; backend: string; runtimeSessionName: string };
|
||||
text: string;
|
||||
mode: string;
|
||||
requestId: string;
|
||||
}): {
|
||||
events: AsyncIterable<unknown>;
|
||||
result: Promise<unknown>;
|
||||
};
|
||||
};
|
||||
expect(deferredRuntime).toBeTruthy();
|
||||
expect(createRealServiceMock).not.toHaveBeenCalled();
|
||||
expect(realServiceStartMock).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
deferredRuntime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:test",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
});
|
||||
|
||||
expect(createRealServiceMock).toHaveBeenCalledWith({
|
||||
pluginConfig: { timeoutSeconds: 10 },
|
||||
});
|
||||
expect(realServiceStartMock).toHaveBeenCalledWith(ctx);
|
||||
expect(runtimeRegistry.get("acpx")?.runtime).toBe(realRuntime);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith("embedded acpx runtime backend registered lazily");
|
||||
|
||||
const turn = deferredRuntime.startTurn({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:test",
|
||||
},
|
||||
text: "hello",
|
||||
mode: "prompt",
|
||||
requestId: "turn-1",
|
||||
});
|
||||
await expect(turn.result).resolves.toEqual({
|
||||
status: "failed",
|
||||
error: {
|
||||
code: "ACP_TURN_FAILED",
|
||||
message: "ACP turn ended without a terminal done event.",
|
||||
},
|
||||
});
|
||||
|
||||
await service.stop?.(ctx as never);
|
||||
|
||||
expect(realServiceStopMock).toHaveBeenCalledWith(ctx);
|
||||
expect(runtimeRegistry.get("acpx")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the explicit runtime skip env as the only outer startup skip", async () => {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = "1";
|
||||
const ctx = createServiceContext();
|
||||
const service = createAcpxRuntimeService();
|
||||
|
||||
await service.start(ctx as never);
|
||||
|
||||
expect(createRealServiceMock).not.toHaveBeenCalled();
|
||||
expect(runtimeRegistry.get("acpx")).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
"skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)",
|
||||
);
|
||||
});
|
||||
});
|
||||
104
extensions/acpx/register.runtime.ts
Normal file
104
extensions/acpx/register.runtime.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Lazy ACPX runtime service registration. The plugin exposes an ACP backend
|
||||
* immediately, then imports the heavier service only when a session needs it.
|
||||
*/
|
||||
import {
|
||||
getAcpRuntimeBackend,
|
||||
registerAcpRuntimeBackend,
|
||||
unregisterAcpRuntimeBackend,
|
||||
type AcpRuntime,
|
||||
} from "openclaw/plugin-sdk/acp-runtime-backend";
|
||||
import type { OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/core";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { createLazyAcpRuntimeProxy } from "./src/runtime-proxy.js";
|
||||
|
||||
const ACPX_BACKEND_ID = "acpx";
|
||||
|
||||
type RealAcpxServiceModule = typeof import("./src/service.js");
|
||||
type CreateAcpxRuntimeServiceParams = NonNullable<
|
||||
Parameters<RealAcpxServiceModule["createAcpxRuntimeService"]>[0]
|
||||
>;
|
||||
|
||||
type DeferredServiceState = {
|
||||
ctx: OpenClawPluginServiceContext | null;
|
||||
params: CreateAcpxRuntimeServiceParams;
|
||||
realRuntime: AcpRuntime | null;
|
||||
realService: OpenClawPluginService | null;
|
||||
startPromise: Promise<AcpRuntime> | null;
|
||||
};
|
||||
|
||||
const loadServiceModule = createLazyRuntimeModule(() => import("./src/service.js"));
|
||||
|
||||
async function startRealService(state: DeferredServiceState): Promise<AcpRuntime> {
|
||||
if (state.realRuntime) {
|
||||
return state.realRuntime;
|
||||
}
|
||||
if (!state.ctx) {
|
||||
throw new Error("ACPX runtime service is not started");
|
||||
}
|
||||
state.startPromise ??= (async () => {
|
||||
const { createAcpxRuntimeService: createAcpxRuntimeServiceLocal } = await loadServiceModule();
|
||||
const service = createAcpxRuntimeServiceLocal(state.params);
|
||||
state.realService = service;
|
||||
await service.start(state.ctx as OpenClawPluginServiceContext);
|
||||
const backend = getAcpRuntimeBackend(ACPX_BACKEND_ID);
|
||||
if (!backend?.runtime) {
|
||||
throw new Error("ACPX runtime service did not register an ACP backend");
|
||||
}
|
||||
state.realRuntime = backend.runtime;
|
||||
return state.realRuntime;
|
||||
})();
|
||||
try {
|
||||
return await state.startPromise;
|
||||
} catch (error) {
|
||||
state.startPromise = null;
|
||||
state.realService = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferredRuntime(state: DeferredServiceState): AcpRuntime {
|
||||
const resolveRuntime = () => startRealService(state);
|
||||
return createLazyAcpRuntimeProxy(resolveRuntime);
|
||||
}
|
||||
|
||||
/** Creates the plugin service that registers ACPX as an ACP runtime backend. */
|
||||
export function createAcpxRuntimeService(
|
||||
params: CreateAcpxRuntimeServiceParams = {},
|
||||
): OpenClawPluginService {
|
||||
const state: DeferredServiceState = {
|
||||
ctx: null,
|
||||
params,
|
||||
realRuntime: null,
|
||||
realService: null,
|
||||
startPromise: null,
|
||||
};
|
||||
|
||||
return {
|
||||
id: "acpx-runtime",
|
||||
async start(ctx) {
|
||||
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
|
||||
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
|
||||
return;
|
||||
}
|
||||
|
||||
state.ctx = ctx;
|
||||
registerAcpRuntimeBackend({
|
||||
id: ACPX_BACKEND_ID,
|
||||
runtime: createDeferredRuntime(state),
|
||||
});
|
||||
ctx.logger.info("embedded acpx runtime backend registered lazily");
|
||||
},
|
||||
async stop(ctx) {
|
||||
if (state.realService) {
|
||||
await state.realService.stop?.(ctx);
|
||||
} else {
|
||||
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
|
||||
}
|
||||
state.ctx = null;
|
||||
state.realRuntime = null;
|
||||
state.realService = null;
|
||||
state.startPromise = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
53
extensions/acpx/runtime-api.ts
Normal file
53
extensions/acpx/runtime-api.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Public runtime API barrel for ACPX. Core and plugin consumers import these
|
||||
* SDK-facing ACP runtime contracts instead of reaching into ACPX internals.
|
||||
*/
|
||||
export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime-backend";
|
||||
export {
|
||||
AcpRuntimeError,
|
||||
getAcpRuntimeBackend,
|
||||
tryDispatchAcpReplyHook,
|
||||
registerAcpRuntimeBackend,
|
||||
unregisterAcpRuntimeBackend,
|
||||
} from "openclaw/plugin-sdk/acp-runtime-backend";
|
||||
export type {
|
||||
AcpRuntime,
|
||||
AcpRuntimeCapabilities,
|
||||
AcpRuntimeDoctorReport,
|
||||
AcpRuntimeEnsureInput,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurn,
|
||||
AcpRuntimeTurnAttachment,
|
||||
AcpRuntimeTurnInput,
|
||||
AcpRuntimeTurnResult,
|
||||
AcpRuntimeTurnResultError,
|
||||
AcpSessionUpdateTag,
|
||||
} from "openclaw/plugin-sdk/acp-runtime-backend";
|
||||
export type {
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginConfigSchema,
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginLogger,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
PluginHookReplyDispatchContext,
|
||||
PluginHookReplyDispatchEvent,
|
||||
PluginHookReplyDispatchResult,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
WindowsSpawnProgram,
|
||||
WindowsSpawnProgramCandidate,
|
||||
WindowsSpawnResolution,
|
||||
} from "openclaw/plugin-sdk/windows-spawn";
|
||||
export {
|
||||
applyWindowsSpawnProgramPolicy,
|
||||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgramCandidate,
|
||||
} from "openclaw/plugin-sdk/windows-spawn";
|
||||
export {
|
||||
listKnownProviderAuthEnvVarNames,
|
||||
omitEnvKeysCaseInsensitive,
|
||||
} from "openclaw/plugin-sdk/provider-env-vars";
|
||||
22
extensions/acpx/setup-api.ts
Normal file
22
extensions/acpx/setup-api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* ACPX setup plugin entry. It auto-enables setup when ACP config already points
|
||||
* at the embedded ACPX runtime backend.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "acpx",
|
||||
name: "ACPX Setup",
|
||||
description: "Lightweight ACPX setup hooks",
|
||||
register(api) {
|
||||
api.registerAutoEnableProbe(({ config }) => {
|
||||
const backendRaw = normalizeLowercaseStringOrEmpty(config.acp?.backend);
|
||||
const configured =
|
||||
config.acp?.enabled === true ||
|
||||
config.acp?.dispatch?.enabled === true ||
|
||||
backendRaw === "acpx";
|
||||
return configured && (!backendRaw || backendRaw === "acpx") ? "ACP runtime configured" : null;
|
||||
});
|
||||
},
|
||||
});
|
||||
245
extensions/acpx/skills/acp-router/SKILL.md
Normal file
245
extensions/acpx/skills/acp-router/SKILL.md
Normal file
@@ -0,0 +1,245 @@
|
||||
---
|
||||
name: acp-router
|
||||
description: Route plain-language requests for Claude Code, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, Qwen, Kiro, Kimi, iFlow, Factory Droid, Kilocode, or explicit ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation. Codex chat binding defaults to the native Codex app-server plugin unless ACP is explicit or background spawn needs ACP.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# ACP Harness Router
|
||||
|
||||
When user intent is "run this in Claude Code/Cursor/Copilot/OpenClaw/OpenCode/Gemini/Qwen/Kiro/Kimi/iFlow/Droid/Kilocode (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
|
||||
|
||||
Codex is special: plain chat/conversation binding and control should use the native Codex app-server plugin (`/codex bind`, `/codex threads`, `/codex resume`) instead of the default ACP path. Use ACP for Codex only when the user explicitly names ACP/`/acp`/acpx, or when spawning background child sessions through `sessions_spawn` where a native Codex runtime spawn is not available yet.
|
||||
|
||||
## Intent detection
|
||||
|
||||
Trigger this skill when the user asks OpenClaw to:
|
||||
|
||||
- run something in Claude Code / Cursor / Copilot / OpenClaw / OpenCode / Gemini / Qwen / Kiro / Kimi / iFlow / Droid / Kilocode
|
||||
- run Codex explicitly through ACP, `/acp`, or acpx
|
||||
- continue existing harness work
|
||||
- relay instructions to an external coding harness
|
||||
- keep an external harness conversation in a thread-like conversation
|
||||
|
||||
Mandatory preflight for coding-agent thread requests:
|
||||
|
||||
- Before creating any thread for ACP harness work, read this skill first in the same turn.
|
||||
- After reading, follow `OpenClaw ACP runtime path` below; do not use `message(action="thread-create")` for ACP harness thread spawn.
|
||||
|
||||
## Mode selection
|
||||
|
||||
Choose one of these paths:
|
||||
|
||||
1. OpenClaw ACP runtime path (default): use `sessions_spawn` / ACP runtime tools.
|
||||
2. Direct `acpx` path (telephone game): use `acpx` CLI through `exec` to drive the harness session directly.
|
||||
|
||||
Use direct `acpx` when one of these is true:
|
||||
|
||||
- user explicitly asks for direct `acpx` driving
|
||||
- ACP runtime/plugin path is unavailable or unhealthy
|
||||
- the task is "just relay prompts to harness" and no OpenClaw ACP lifecycle features are needed
|
||||
|
||||
Do not use:
|
||||
|
||||
- `subagents` runtime for harness control
|
||||
- `/acp` command delegation as a requirement for the user
|
||||
- PTY scraping of supported ACP harness CLIs when `acpx` is available
|
||||
|
||||
## AgentId mapping
|
||||
|
||||
Use these defaults when user names a harness directly:
|
||||
|
||||
- "openclaw" -> `agentId: "openclaw"`
|
||||
- "claude" or "claude code" -> `agentId: "claude"`
|
||||
- "codex" -> `agentId: "codex"` only for explicit ACP/acpx requests or background ACP runtime spawn
|
||||
- "copilot" or "github copilot" -> `agentId: "copilot"`
|
||||
- "cursor" or "cursor cli" -> `agentId: "cursor"`
|
||||
- "droid" or "factory droid" -> `agentId: "droid"`
|
||||
- "opencode" -> `agentId: "opencode"`
|
||||
- "gemini" or "gemini cli" -> `agentId: "gemini"`
|
||||
- "iflow" -> `agentId: "iflow"`
|
||||
- "kilocode" -> `agentId: "kilocode"`
|
||||
- "kimi" or "kimi cli" -> `agentId: "kimi"`
|
||||
- "kiro" or "kiro cli" -> `agentId: "kiro"`
|
||||
- "qwen" or "qwen code" -> `agentId: "qwen"`
|
||||
|
||||
These defaults match current acpx built-in aliases.
|
||||
|
||||
If policy rejects the chosen id, report the policy error clearly and ask for the allowed ACP agent id.
|
||||
|
||||
## OpenClaw ACP runtime path
|
||||
|
||||
Required behavior:
|
||||
|
||||
1. For ACP harness thread spawn requests, read this skill first in the same turn before calling tools.
|
||||
2. Use `sessions_spawn` with:
|
||||
- `runtime: "acp"`
|
||||
- `thread: true`
|
||||
- `mode: "session"` (unless user explicitly wants one-shot)
|
||||
3. For ACP harness thread creation, do not use `message` with `action=thread-create`; `sessions_spawn` is the only thread-create path.
|
||||
4. Put requested work in `task` so the ACP session gets it immediately.
|
||||
5. Set `agentId` explicitly unless ACP default agent is known.
|
||||
6. Do not ask user to run slash commands or CLI when this path works directly.
|
||||
|
||||
Example:
|
||||
|
||||
User: "spawn a test codex ACP session in thread and tell it to say hi"
|
||||
|
||||
Call:
|
||||
|
||||
```json
|
||||
{
|
||||
"task": "Say hi.",
|
||||
"runtime": "acp",
|
||||
"agentId": "codex",
|
||||
"thread": true,
|
||||
"mode": "session"
|
||||
}
|
||||
```
|
||||
|
||||
## Thread spawn recovery policy
|
||||
|
||||
When the user asks to start a coding harness in a thread, treat that as an ACP runtime request and try to satisfy it end-to-end.
|
||||
|
||||
Required behavior when ACP backend is unavailable:
|
||||
|
||||
1. Do not immediately ask the user to pick an alternate path.
|
||||
2. First attempt automatic local repair:
|
||||
- ensure plugin-local pinned acpx is installed in the ACPX plugin package
|
||||
- verify `${ACPX_CMD} --version`
|
||||
3. After reinstall/repair, restart the gateway and explicitly offer to run that restart for the user.
|
||||
4. Retry ACP thread spawn once after repair.
|
||||
5. Only if repair+retry fails, report the concrete error and then offer fallback options.
|
||||
|
||||
When offering fallback, keep ACP first:
|
||||
|
||||
- Option 1: retry ACP spawn after showing exact failing step
|
||||
- Option 2: direct acpx telephone-game flow
|
||||
|
||||
Do not default to subagent runtime for these requests.
|
||||
|
||||
## ACPX install and version policy (direct acpx path)
|
||||
|
||||
For this repo, direct `acpx` calls must follow the same pinned policy as the `@openclaw/acpx` extension package.
|
||||
|
||||
1. Prefer plugin-local binary, not global PATH:
|
||||
- `${ACPX_PLUGIN_ROOT}/node_modules/.bin/acpx`
|
||||
2. Resolve pinned version from extension dependency:
|
||||
- `node -e "console.log(require(process.env.ACPX_PLUGIN_ROOT + '/package.json').dependencies.acpx)"`
|
||||
3. If binary is missing or version mismatched, install plugin-local pinned version:
|
||||
- `cd "$ACPX_PLUGIN_ROOT" && npm install --omit=dev --no-save acpx@<pinnedVersion>`
|
||||
4. Verify before use:
|
||||
- `${ACPX_PLUGIN_ROOT}/node_modules/.bin/acpx --version`
|
||||
5. If install/repair changed ACPX artifacts, restart the gateway and offer to run the restart.
|
||||
6. Do not run `npm install -g acpx` unless the user explicitly asks for global install.
|
||||
|
||||
Set and reuse:
|
||||
|
||||
```bash
|
||||
ACPX_PLUGIN_ROOT="<bundled-acpx-plugin-root>"
|
||||
ACPX_CMD="$ACPX_PLUGIN_ROOT/node_modules/.bin/acpx"
|
||||
```
|
||||
|
||||
## Direct acpx path ("telephone game")
|
||||
|
||||
Use this path to drive harness sessions without `/acp` or subagent runtime.
|
||||
|
||||
### Rules
|
||||
|
||||
1. Use `exec` commands that call `${ACPX_CMD}`.
|
||||
2. Reuse a stable session name per conversation so follow-up prompts stay in the same harness context.
|
||||
3. Prefer `--format quiet` for clean assistant text to relay back to user.
|
||||
4. Use `exec` (one-shot) only when the user wants one-shot behavior.
|
||||
5. Keep working directory explicit (`--cwd`) when task scope depends on repo context.
|
||||
|
||||
### Session naming
|
||||
|
||||
Use a deterministic name, for example:
|
||||
|
||||
- `oc-<harness>-<conversationId>`
|
||||
|
||||
Where `conversationId` is thread id when available, otherwise channel/conversation id.
|
||||
|
||||
### Command templates
|
||||
|
||||
Persistent session (create if missing, then prompt):
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex sessions show oc-codex-<conversationId> \
|
||||
|| ${ACPX_CMD} codex sessions new --name oc-codex-<conversationId>
|
||||
|
||||
${ACPX_CMD} codex -s oc-codex-<conversationId> --cwd <workspacePath> --format quiet "<prompt>"
|
||||
```
|
||||
|
||||
One-shot:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex exec --cwd <workspacePath> --format quiet "<prompt>"
|
||||
```
|
||||
|
||||
Cancel in-flight turn:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex cancel -s oc-codex-<conversationId>
|
||||
```
|
||||
|
||||
Close session:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex sessions close oc-codex-<conversationId>
|
||||
```
|
||||
|
||||
### Harness aliases in acpx
|
||||
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `copilot`
|
||||
- `cursor`
|
||||
- `droid`
|
||||
- `gemini`
|
||||
- `iflow`
|
||||
- `kilocode`
|
||||
- `kimi`
|
||||
- `kiro`
|
||||
- `openclaw`
|
||||
- `opencode`
|
||||
- `qwen`
|
||||
|
||||
### Built-in adapter commands in acpx
|
||||
|
||||
Defaults are:
|
||||
|
||||
- `openclaw -> openclaw acp`
|
||||
- `claude -> bundled @agentclientprotocol/claude-agent-acp@0.55.0`
|
||||
- `codex -> bundled @zed-industries/codex-acp@0.16.0 through OpenClaw's isolated CODEX_HOME wrapper`
|
||||
- `copilot -> copilot --acp --stdio`
|
||||
- `cursor -> cursor-agent acp`
|
||||
- `droid -> droid exec --output-format acp`
|
||||
- `gemini -> gemini --acp`
|
||||
- `iflow -> iflow --experimental-acp`
|
||||
- `kilocode -> npx -y @kilocode/cli acp`
|
||||
- `kimi -> kimi acp`
|
||||
- `kiro -> kiro-cli acp`
|
||||
- `opencode -> npx -y opencode-ai acp`
|
||||
- `qwen -> qwen --acp`
|
||||
|
||||
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
|
||||
If your local Cursor install still exposes ACP as `agent acp`, set that as the `cursor` agent override explicitly.
|
||||
|
||||
### Failure handling
|
||||
|
||||
- `acpx: command not found`:
|
||||
- for thread-spawn ACP requests, install plugin-local pinned acpx in the ACPX plugin package immediately
|
||||
- restart gateway after install and offer to run the restart automatically
|
||||
- then retry once
|
||||
- do not ask for install permission first unless policy explicitly requires it
|
||||
- do not install global `acpx` unless explicitly requested
|
||||
- adapter command missing (for example `claude-agent-acp` not found):
|
||||
- for thread-spawn ACP requests, first restore built-in defaults by removing broken `~/.acpx/config.json` agent overrides
|
||||
- then retry once before offering fallback
|
||||
- if user wants binary-based overrides, install exactly the configured adapter binary
|
||||
- `NO_SESSION`: run `${ACPX_CMD} <agent> sessions new --name <sessionName>` then retry prompt.
|
||||
- queue busy: either wait for completion (default) or use `--no-wait` when async behavior is explicitly desired.
|
||||
|
||||
### Output relay
|
||||
|
||||
When relaying to user, return the final assistant text output from `acpx` command result. Avoid relaying raw local tool noise unless user asked for verbose logs.
|
||||
160
extensions/acpx/src/claude-agent-acp-completion.test.ts
Normal file
160
extensions/acpx/src/claude-agent-acp-completion.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// ACPX tests cover claude agent acp completion plugin behavior.
|
||||
import { ClaudeAcpAgent } from "@agentclientprotocol/claude-agent-acp";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
type IteratorResultResolver = (value: IteratorResult<unknown>) => void;
|
||||
|
||||
class ManualAsyncIterator implements AsyncIterator<unknown> {
|
||||
private readonly pending: IteratorResultResolver[] = [];
|
||||
private readonly queued: IteratorResult<unknown>[] = [];
|
||||
|
||||
next(): Promise<IteratorResult<unknown>> {
|
||||
const next = this.queued.shift();
|
||||
if (next) {
|
||||
return Promise.resolve(next);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.pending.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
push(value: unknown): void {
|
||||
this.resolve({ value, done: false });
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.resolve({ value: undefined, done: true });
|
||||
}
|
||||
|
||||
private resolve(value: IteratorResult<unknown>): void {
|
||||
const pending = this.pending.shift();
|
||||
if (pending) {
|
||||
pending(value);
|
||||
return;
|
||||
}
|
||||
this.queued.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
function makeResultMessage(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
result: "finished",
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIdleMessage() {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "session_state_changed",
|
||||
state: "idle",
|
||||
session_id: "session-1",
|
||||
};
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function createAgentWithSession(query: ManualAsyncIterator) {
|
||||
const agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: vi.fn(),
|
||||
extNotification: vi.fn(),
|
||||
} as unknown as ConstructorParameters<typeof ClaudeAcpAgent>[0]);
|
||||
agent.sessions["session-1"] = {
|
||||
cancelled: false,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
contextWindowSize: 200_000,
|
||||
cwd: "/tmp",
|
||||
emitRawSDKMessages: false,
|
||||
input: {
|
||||
push: vi.fn(),
|
||||
end: vi.fn(),
|
||||
},
|
||||
nextPendingOrder: 0,
|
||||
pendingMessages: new Map(),
|
||||
promptRunning: false,
|
||||
query,
|
||||
settingsManager: {
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as unknown as (typeof agent.sessions)[string];
|
||||
return agent;
|
||||
}
|
||||
|
||||
describe("claude-agent-acp completion", () => {
|
||||
it("does not resolve a prompt on idle before the result message", async () => {
|
||||
const query = new ManualAsyncIterator();
|
||||
const agent = createAgentWithSession(query);
|
||||
|
||||
let resolved = false;
|
||||
const promptPromise = agent
|
||||
.prompt({
|
||||
sessionId: "session-1",
|
||||
prompt: [{ type: "text", text: "do work" }],
|
||||
})
|
||||
.then((value) => {
|
||||
resolved = true;
|
||||
return value;
|
||||
});
|
||||
|
||||
query.push(makeIdleMessage());
|
||||
await flushMicrotasks();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
query.push(makeResultMessage());
|
||||
const result = await promptPromise;
|
||||
expect(result.stopReason).toBe("end_turn");
|
||||
expect(result.usage?.inputTokens).toBe(1);
|
||||
expect(result.usage?.outputTokens).toBe(1);
|
||||
});
|
||||
|
||||
it("does not resolve a prompt after a task-notification result goes idle", async () => {
|
||||
const query = new ManualAsyncIterator();
|
||||
const agent = createAgentWithSession(query);
|
||||
|
||||
let resolved = false;
|
||||
const promptPromise = agent
|
||||
.prompt({
|
||||
sessionId: "session-1",
|
||||
prompt: [{ type: "text", text: "do foreground work" }],
|
||||
})
|
||||
.then((value) => {
|
||||
resolved = true;
|
||||
return value;
|
||||
});
|
||||
|
||||
query.push(makeResultMessage({ origin: { kind: "task-notification" } }));
|
||||
await flushMicrotasks();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
query.push(makeIdleMessage());
|
||||
await flushMicrotasks();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
query.push(makeResultMessage());
|
||||
const result = await promptPromise;
|
||||
expect(result.stopReason).toBe("end_turn");
|
||||
// Background task-notification usage stays out of the foreground prompt response.
|
||||
expect(result.usage?.inputTokens).toBe(1);
|
||||
expect(result.usage?.outputTokens).toBe(1);
|
||||
});
|
||||
});
|
||||
808
extensions/acpx/src/codex-auth-bridge.test.ts
Normal file
808
extensions/acpx/src/codex-auth-bridge.test.ts
Normal file
@@ -0,0 +1,808 @@
|
||||
// ACPX tests cover codex auth bridge plugin behavior.
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
|
||||
import { resolveAcpxPluginConfig } from "./config.js";
|
||||
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const tempDirs: string[] = [];
|
||||
const previousEnv = {
|
||||
CODEX_HOME: process.env.CODEX_HOME,
|
||||
OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR,
|
||||
};
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-codex-auth-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function quoteArg(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function restoreEnv(name: keyof typeof previousEnv): void {
|
||||
const value = previousEnv[name];
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function generatedCodexPaths(stateDir: string): {
|
||||
configPath: string;
|
||||
wrapperPath: string;
|
||||
} {
|
||||
const baseDir = path.join(stateDir, "acpx");
|
||||
const codexHome = path.join(baseDir, "codex-home");
|
||||
return {
|
||||
configPath: path.join(codexHome, "config.toml"),
|
||||
wrapperPath: path.join(baseDir, "codex-acp-wrapper.mjs"),
|
||||
};
|
||||
}
|
||||
|
||||
function generatedClaudePaths(stateDir: string): {
|
||||
wrapperPath: string;
|
||||
} {
|
||||
const baseDir = path.join(stateDir, "acpx");
|
||||
return {
|
||||
wrapperPath: path.join(baseDir, "claude-agent-acp-wrapper.mjs"),
|
||||
};
|
||||
}
|
||||
|
||||
function expectCodexWrapperCommand(command: string | undefined, wrapperPath: string): void {
|
||||
expect(command).toContain(quoteArg(process.execPath));
|
||||
expect(command).toContain(quoteArg(wrapperPath));
|
||||
}
|
||||
|
||||
function expectClaudeWrapperCommand(command: string | undefined, wrapperPath: string): void {
|
||||
expect(command).toContain(quoteArg(process.execPath));
|
||||
expect(command).toContain(quoteArg(wrapperPath));
|
||||
}
|
||||
|
||||
function expectWrapperToContainPathSuffix(wrapper: string, pathSuffix: string[]): void {
|
||||
const nativeSuffix = pathSuffix.join(path.sep);
|
||||
const escapedNativeSuffix = JSON.stringify(nativeSuffix).slice(1, -1);
|
||||
const posixSuffix = pathSuffix.join("/");
|
||||
if (wrapper.includes(escapedNativeSuffix)) {
|
||||
expect(wrapper).toContain(escapedNativeSuffix);
|
||||
} else {
|
||||
expect(wrapper).toContain(posixSuffix);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
let error: unknown;
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
restoreEnv("CODEX_HOME");
|
||||
restoreEnv("OPENCLAW_AGENT_DIR");
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("prepareAcpxCodexAuthConfig", () => {
|
||||
it("installs an isolated Codex ACP wrapper without synthesizing auth from canonical OpenClaw OAuth", async () => {
|
||||
const root = await makeTempDir();
|
||||
const agentDir = path.join(root, "agent");
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const generatedClaude = generatedClaudePaths(stateDir);
|
||||
const installedBinPath = path.join(
|
||||
root,
|
||||
"node_modules",
|
||||
"@zed-industries",
|
||||
"codex-acp",
|
||||
"bin",
|
||||
"codex-acp.js",
|
||||
);
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||||
});
|
||||
|
||||
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
|
||||
expectClaudeWrapperCommand(resolved.agents.claude, generatedClaude.wrapperPath);
|
||||
await expect(fs.access(generated.wrapperPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(generatedClaude.wrapperPath)).resolves.toBeUndefined();
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain(JSON.stringify(installedBinPath));
|
||||
expect(wrapper).toContain("defaultArgs = [installedBinPath]");
|
||||
await expectPathMissing(path.join(agentDir, "acp-auth", "codex", "auth.json"));
|
||||
});
|
||||
|
||||
it("keeps generated wrappers usable when chmod is rejected by the state filesystem", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generatedCodex = generatedCodexPaths(stateDir);
|
||||
const generatedClaude = generatedClaudePaths(stateDir);
|
||||
const chmodError = Object.assign(new Error("operation not permitted"), { code: "EPERM" });
|
||||
const chmodSpy = vi.spyOn(fs, "chmod").mockRejectedValue(chmodError);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
});
|
||||
|
||||
expect(chmodSpy).toHaveBeenCalledWith(generatedCodex.wrapperPath, 0o755);
|
||||
expect(chmodSpy).toHaveBeenCalledWith(generatedClaude.wrapperPath, 0o755);
|
||||
expectCodexWrapperCommand(resolved.agents.codex, generatedCodex.wrapperPath);
|
||||
expectClaudeWrapperCommand(resolved.agents.claude, generatedClaude.wrapperPath);
|
||||
await expect(fs.access(generatedCodex.wrapperPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(generatedClaude.wrapperPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the current Codex ACP package range when the local adapter is unavailable", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => undefined,
|
||||
});
|
||||
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain('"@zed-industries/codex-acp@0.16.0"');
|
||||
expect(wrapper).toContain('"--", "codex-acp"');
|
||||
expect(wrapper).not.toContain("@zed-industries/codex-acp@^0.11.1");
|
||||
});
|
||||
|
||||
it("falls back to the patched Claude ACP package when the local adapter is unavailable", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledClaudeAcpBinPath: async () => undefined,
|
||||
});
|
||||
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain('"@agentclientprotocol/claude-agent-acp@0.55.0"');
|
||||
expect(wrapper).toContain('"--", "claude-agent-acp"');
|
||||
expect(wrapper).not.toContain("@agentclientprotocol/claude-agent-acp@^0.31.0");
|
||||
expect(wrapper).not.toContain("@agentclientprotocol/claude-agent-acp@0.31.0");
|
||||
});
|
||||
|
||||
it("uses the bundled Codex ACP dependency by default when it is installed", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
});
|
||||
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain("@zed-industries/codex-acp");
|
||||
expectWrapperToContainPathSuffix(wrapper, ["bin", "codex-acp.js"]);
|
||||
expect(wrapper).toContain("defaultArgs = [installedBinPath]");
|
||||
});
|
||||
|
||||
it("keeps the orphaned wrapper alive long enough to force-kill the child process group", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
});
|
||||
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain('killChildTree("SIGTERM")');
|
||||
expect(wrapper).toContain('killChildTree("SIGKILL", { force: true })');
|
||||
expect(wrapper).toMatch(
|
||||
/forceKillTimer = setTimeout\(\(\) => \{\s*killChildTree\("SIGKILL", \{ force: true \}\);\s*childExitCode = 1;/s,
|
||||
);
|
||||
expect(wrapper).toMatch(
|
||||
/child\.on\("exit", \(code, signal\) => \{\s*if \(parentWatcher\) \{\s*clearInterval\(parentWatcher\);\s*\}\s*if \(orphanCleanupStarted\) \{\s*return;\s*\}/s,
|
||||
);
|
||||
expect(wrapper).toMatch(
|
||||
/child\.on\("close", \(\) => \{\s*finishStderrLog\(\);\s*process\.exit\(childExitCode\);/s,
|
||||
);
|
||||
expect(wrapper).not.toMatch(
|
||||
/forceKillTimer = setTimeout\(\(\) => killChildTree\("SIGKILL"\), 1_500\);\s*forceKillTimer\.unref\?\.\(\);\s*process\.exit\(1\);/s,
|
||||
);
|
||||
// Orphan detection must trigger on any PPID change, not only when the new
|
||||
// PPID is init (1). Systemd user services and container init reparent
|
||||
// orphaned processes to a session manager or container init (PID != 1),
|
||||
// and the older `process.ppid !== 1` guard would silently leak the codex
|
||||
// adapter tree there.
|
||||
expect(wrapper).not.toContain("process.ppid !== 1");
|
||||
expect(wrapper).toMatch(
|
||||
/setInterval\(\(\) => \{[\s\S]*?if \(process\.ppid === originalParentPid\) \{\s*return;\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
});
|
||||
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain("@agentclientprotocol/claude-agent-acp");
|
||||
expectWrapperToContainPathSuffix(wrapper, ["dist", "index.js"]);
|
||||
expect(wrapper).toContain("defaultArgs = [installedBinPath]");
|
||||
});
|
||||
|
||||
it("launches the locally installed Codex ACP bin with isolated CODEX_HOME", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const installedBinPath = path.join(root, "codex-acp-bin.js");
|
||||
await fs.writeFile(
|
||||
installedBinPath,
|
||||
"console.log(JSON.stringify({ argv: process.argv.slice(2), codexHome: process.env.CODEX_HOME }));\n",
|
||||
"utf8",
|
||||
);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||||
});
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
generated.wrapperPath,
|
||||
"--openclaw-acpx-lease-id",
|
||||
"lease-1",
|
||||
"--openclaw-gateway-instance-id",
|
||||
"gateway-1",
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
},
|
||||
);
|
||||
const launched = JSON.parse(stdout.trim()) as { argv?: unknown; codexHome?: unknown };
|
||||
expect(launched.argv).toStrictEqual([]);
|
||||
const expectedCodexHome = await fs.realpath(path.join(stateDir, "acpx", "codex-home"));
|
||||
expect(path.resolve(String(launched.codexHome))).toBe(expectedCodexHome);
|
||||
});
|
||||
|
||||
it("writes API-key auth into the isolated Codex ACP home when env auth is present", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const installedBinPath = path.join(root, "codex-acp-bin.js");
|
||||
await fs.writeFile(
|
||||
installedBinPath,
|
||||
"console.log(JSON.stringify({ codexHome: process.env.CODEX_HOME }));\n",
|
||||
"utf8",
|
||||
);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||||
});
|
||||
|
||||
await execFileAsync(process.execPath, [generated.wrapperPath], {
|
||||
cwd: root,
|
||||
env: { ...process.env, CODEX_API_KEY: "", OPENAI_API_KEY: "sk-test-api-key" },
|
||||
});
|
||||
|
||||
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
|
||||
const auth = JSON.parse(await fs.readFile(authPath, "utf8")) as {
|
||||
auth_mode?: unknown;
|
||||
OPENAI_API_KEY?: unknown;
|
||||
};
|
||||
expect(auth).toMatchObject({
|
||||
OPENAI_API_KEY: "sk-test-api-key",
|
||||
});
|
||||
expect(auth).not.toHaveProperty("auth_mode");
|
||||
if (process.platform !== "win32") {
|
||||
const mode = (await fs.stat(authPath)).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves existing isolated Codex auth when env auth is present", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const installedBinPath = path.join(root, "codex-acp-bin.js");
|
||||
await fs.writeFile(installedBinPath, "console.log('ok');\n", "utf8");
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||||
});
|
||||
|
||||
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
|
||||
const existingAuth = {
|
||||
auth_mode: "chatgpt",
|
||||
tokens: { access_token: "existing-token" },
|
||||
last_refresh: null,
|
||||
};
|
||||
await fs.writeFile(authPath, `${JSON.stringify(existingAuth)}\n`, { mode: 0o600 });
|
||||
|
||||
await execFileAsync(process.execPath, [generated.wrapperPath], {
|
||||
cwd: root,
|
||||
env: { ...process.env, OPENAI_API_KEY: "sk-test-api-key" },
|
||||
});
|
||||
|
||||
expect(JSON.parse(await fs.readFile(authPath, "utf8"))).toEqual(existingAuth);
|
||||
});
|
||||
|
||||
it("updates existing isolated Codex API-key auth when env auth changes", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const installedBinPath = path.join(root, "codex-acp-bin.js");
|
||||
await fs.writeFile(installedBinPath, "console.log('ok');\n", "utf8");
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||||
});
|
||||
|
||||
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
|
||||
await fs.writeFile(
|
||||
authPath,
|
||||
`${JSON.stringify({
|
||||
OPENAI_API_KEY: "sk-old-api-key",
|
||||
tokens: null,
|
||||
last_refresh: null,
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
await execFileAsync(process.execPath, [generated.wrapperPath], {
|
||||
cwd: root,
|
||||
env: { ...process.env, CODEX_API_KEY: "sk-new-api-key", OPENAI_API_KEY: "sk-other-key" },
|
||||
});
|
||||
|
||||
expect(JSON.parse(await fs.readFile(authPath, "utf8"))).toMatchObject({
|
||||
OPENAI_API_KEY: "sk-new-api-key",
|
||||
tokens: null,
|
||||
last_refresh: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("launches the locally installed Claude ACP bin without going through npm", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const installedBinPath = path.join(root, "claude-agent-acp-bin.js");
|
||||
await fs.writeFile(
|
||||
installedBinPath,
|
||||
"console.log(JSON.stringify({ argv: process.argv.slice(2), codexHome: process.env.CODEX_HOME ?? null }));\n",
|
||||
"utf8",
|
||||
);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledClaudeAcpBinPath: async () => installedBinPath,
|
||||
});
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[generated.wrapperPath, "--permission-mode", "bypass"],
|
||||
{
|
||||
cwd: root,
|
||||
},
|
||||
);
|
||||
const launched = JSON.parse(stdout.trim()) as { argv?: unknown; codexHome?: unknown };
|
||||
expect(launched.argv).toEqual(["--permission-mode", "bypass"]);
|
||||
expect(launched.codexHome).toBeNull();
|
||||
});
|
||||
|
||||
it("does not copy source Codex auth", async () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const agentDir = path.join(root, "agent");
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "auth.json"),
|
||||
`${JSON.stringify({ auth_mode: "apikey", OPENAI_API_KEY: "test-api-key" }, null, 2)}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "config.toml"),
|
||||
[
|
||||
'model = "gpt-5.5-1"',
|
||||
'model_provider = "azure_foundry"',
|
||||
'model_reasoning_effort = "high"',
|
||||
'sandbox_mode = "workspace-write"',
|
||||
'notify = ["SkyComputerUseClient", "turn-ended"]',
|
||||
"",
|
||||
"[model_providers.azure_foundry]",
|
||||
'name = "Azure Foundry"',
|
||||
'base_url = "https://example.azure.com/openai/v1"',
|
||||
'wire_api = "responses"',
|
||||
'env_key = "AZURE_OPENAI_API_KEY"',
|
||||
'http_headers = { "api-key" = "inline-secret-key" }',
|
||||
'query_params = { "api-version" = "2026-01-01", "secret" = "inline-secret-param" }',
|
||||
'experimental_bearer_token = "inline-secret-bearer"',
|
||||
"",
|
||||
"[model_providers.azure_foundry.auth]",
|
||||
'command = "bash"',
|
||||
'args = ["-lc", "printf %s test-key"]',
|
||||
"",
|
||||
"[model_providers.secret_only]",
|
||||
'experimental_bearer_token = "secret-only-token"',
|
||||
"",
|
||||
`[projects.${JSON.stringify(path.join(root, "project-with-model-key"))}]`,
|
||||
'model = "nested-project-model"',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
process.env.CODEX_HOME = sourceCodexHome;
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => undefined,
|
||||
});
|
||||
|
||||
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
|
||||
const isolatedConfig = await fs.readFile(generated.configPath, "utf8");
|
||||
expect(isolatedConfig).toContain('model = "gpt-5.5-1"');
|
||||
expect(isolatedConfig).toContain('model_provider = "azure_foundry"');
|
||||
expect(isolatedConfig).toContain('model_reasoning_effort = "high"');
|
||||
expect(isolatedConfig).toContain('sandbox_mode = "workspace-write"');
|
||||
expect(isolatedConfig).toContain("[model_providers.azure_foundry]");
|
||||
expect(isolatedConfig).toContain('base_url = "https://example.azure.com/openai/v1"');
|
||||
expect(isolatedConfig).toContain('env_key = "AZURE_OPENAI_API_KEY"');
|
||||
expect(isolatedConfig).not.toContain("http_headers");
|
||||
expect(isolatedConfig).not.toContain("query_params");
|
||||
expect(isolatedConfig).not.toContain("experimental_bearer_token");
|
||||
expect(isolatedConfig).not.toContain("[model_providers.azure_foundry.auth]");
|
||||
expect(isolatedConfig).not.toContain("[model_providers.secret_only]");
|
||||
expect(isolatedConfig).not.toContain("nested-project-model");
|
||||
expect(isolatedConfig).not.toContain("inline-secret");
|
||||
expect(isolatedConfig).not.toContain('args = ["-lc", "printf %s test-key"]');
|
||||
expect(isolatedConfig).not.toContain("notify");
|
||||
expect(isolatedConfig).not.toContain("SkyComputerUseClient");
|
||||
expect(isolatedConfig).toContain(`[projects.${JSON.stringify(path.resolve(root))}]`);
|
||||
expect(isolatedConfig).toContain('trust_level = "trusted"');
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain("CODEX_HOME: codexHome");
|
||||
expect(wrapper).not.toContain(sourceCodexHome);
|
||||
await expectPathMissing(path.join(agentDir, "acp-auth", "codex-source", "auth.json"));
|
||||
await expectPathMissing(path.join(agentDir, "acp-auth", "codex", "auth.json"));
|
||||
});
|
||||
|
||||
it("copies only trusted Codex project declarations into the isolated Codex home", async () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const stateDir = path.join(root, "state");
|
||||
const explicitProject = path.join(root, "explicit project");
|
||||
const inlineProject = path.join(root, "inline-project");
|
||||
const mapProject = path.join(root, "map-project");
|
||||
const untrustedProject = path.join(root, "untrusted-project");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "config.toml"),
|
||||
[
|
||||
'notify = ["SkyComputerUseClient", "turn-ended"]',
|
||||
`projects = { ${JSON.stringify(mapProject)} = { trust_level = "trusted" }, ${JSON.stringify(untrustedProject)} = { trust_level = "untrusted" } }`,
|
||||
"[projects]",
|
||||
`${JSON.stringify(inlineProject)} = { trust_level = "trusted" }`,
|
||||
`[projects.${JSON.stringify(explicitProject)}]`,
|
||||
'trust_level = "trusted"',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
process.env.CODEX_HOME = sourceCodexHome;
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => undefined,
|
||||
});
|
||||
|
||||
const isolatedConfig = await fs.readFile(generated.configPath, "utf8");
|
||||
expect(isolatedConfig).toContain(`[projects.${JSON.stringify(path.resolve(root))}]`);
|
||||
expect(isolatedConfig).toContain(`[projects.${JSON.stringify(path.resolve(explicitProject))}]`);
|
||||
expect(isolatedConfig).toContain(`[projects.${JSON.stringify(path.resolve(inlineProject))}]`);
|
||||
expect(isolatedConfig).toContain(`[projects.${JSON.stringify(path.resolve(mapProject))}]`);
|
||||
expect(isolatedConfig).not.toContain(untrustedProject);
|
||||
expect(isolatedConfig).not.toContain("notify");
|
||||
expect(isolatedConfig).not.toContain("SkyComputerUseClient");
|
||||
});
|
||||
|
||||
it("normalizes an explicitly configured Codex ACP command to the local wrapper", async () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "config.toml"),
|
||||
'notify = ["SkyComputerUseClient", "turn-ended"]\n',
|
||||
);
|
||||
process.env.CODEX_HOME = sourceCodexHome;
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
codex: {
|
||||
command: "npx @zed-industries/codex-acp@0.12.0 -c 'model=\"gpt-5.4\"'",
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => path.join(root, "codex-acp.js"),
|
||||
});
|
||||
|
||||
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
|
||||
expect(resolved.agents.codex).not.toContain("npx @zed-industries/codex-acp@0.12.0");
|
||||
expect(resolved.agents.codex).toContain(quoteArg("-c"));
|
||||
expect(resolved.agents.codex).toContain(quoteArg('model="gpt-5.4"'));
|
||||
const isolatedConfig = await fs.readFile(generated.configPath, "utf8");
|
||||
expect(isolatedConfig).not.toContain("notify");
|
||||
expect(isolatedConfig).not.toContain("SkyComputerUseClient");
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain("process.argv.slice(2)");
|
||||
expect(wrapper).toContain("CODEX_HOME: codexHome");
|
||||
expect(wrapper).not.toContain(sourceCodexHome);
|
||||
});
|
||||
|
||||
it("normalizes an explicitly configured Claude ACP npx command to the local wrapper", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
claude: {
|
||||
command: "npx -y @agentclientprotocol/claude-agent-acp@0.31.4 --permission-mode bypass",
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
|
||||
});
|
||||
|
||||
expectClaudeWrapperCommand(resolved.agents.claude, generated.wrapperPath);
|
||||
expect(resolved.agents.claude).not.toContain("npx -y @agentclientprotocol/claude-agent-acp");
|
||||
expect(resolved.agents.claude).toContain("--permission-mode");
|
||||
expect(resolved.agents.claude).toContain("bypass");
|
||||
});
|
||||
|
||||
it("captures Codex wrapper stderr in a stream-aware redacted per-lease log", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const stderrScript = path.join(root, "emit-stderr.mjs");
|
||||
await fs.writeFile(
|
||||
stderrScript,
|
||||
`const chunks = [
|
||||
"token=sk-test",
|
||||
"secret1234567890\\n",
|
||||
"Authorization: Bearer bearer-secret",
|
||||
"-token-1234567890\\n",
|
||||
'{"client_secret":"json-secret-1234567890","api_key":"json-api-key-1234567890"}\\n',
|
||||
"client-secret: kebab-secret-1234567890\\n",
|
||||
"standalone sk-live-secret",
|
||||
"1234567890\\n",
|
||||
"url=https://example.test/callback?token=query-secret",
|
||||
"-1234567890\\n",
|
||||
"github_pat_1234567890",
|
||||
"abcdefghijklmnopqrstuvwxyz\\n",
|
||||
"-----BEGIN PRIVATE KEY-----\\nprivate-secret-body\\n",
|
||||
"-----END PRIVATE KEY-----\\n",
|
||||
"tail-token=tail-secret-1234567890",
|
||||
"\\n-----BEGIN PRIVATE KEY-----\\ntruncated-private-secret",
|
||||
];
|
||||
let index = 0;
|
||||
function writeNext() {
|
||||
if (index >= chunks.length) {
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
process.stderr.write(chunks[index]);
|
||||
index += 1;
|
||||
setTimeout(writeNext, 5);
|
||||
}
|
||||
writeNext();`,
|
||||
"utf8",
|
||||
);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
codex: {
|
||||
command: `${process.execPath} ${stderrScript}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => path.join(root, "codex-acp.js"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
execFileAsync(process.execPath, [
|
||||
generated.wrapperPath,
|
||||
"--openclaw-run-configured",
|
||||
process.execPath,
|
||||
stderrScript,
|
||||
OPENCLAW_ACPX_LEASE_ID_ARG,
|
||||
"lease-secret",
|
||||
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
|
||||
"gateway-test",
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 1 });
|
||||
|
||||
const log = await fs.readFile(
|
||||
path.join(stateDir, "acpx", "codex-acp-wrapper.stderr.lease-secret.log"),
|
||||
"utf8",
|
||||
);
|
||||
expect(log).toContain("token=[REDACTED]");
|
||||
expect(log).toContain("Authorization: Bearer [REDACTED]");
|
||||
expect(log).toContain('"client_secret":"[REDACTED]"');
|
||||
expect(log).toContain('"api_key":"[REDACTED]"');
|
||||
expect(log).toContain("client-secret: [REDACTED]");
|
||||
expect(log).toContain("standalone [REDACTED_OPENAI_KEY]");
|
||||
expect(log).toContain("?token=[REDACTED]");
|
||||
expect(log).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||
expect(log).toContain("[REDACTED_PRIVATE_KEY]");
|
||||
expect(log).toContain("tail-token=[REDACTED]");
|
||||
expect(log).not.toContain("sk-testsecret1234567890");
|
||||
expect(log).not.toContain("bearer-secret-token-1234567890");
|
||||
expect(log).not.toContain("json-secret-1234567890");
|
||||
expect(log).not.toContain("json-api-key-1234567890");
|
||||
expect(log).not.toContain("kebab-secret-1234567890");
|
||||
expect(log).not.toContain("query-secret-1234567890");
|
||||
expect(log).not.toContain("github_pat_1234567890abcdefghijklmnopqrstuvwxyz");
|
||||
expect(log).not.toContain("private-secret-body");
|
||||
expect(log).not.toContain("truncated-private-secret");
|
||||
expect(log).not.toContain("tail-secret-1234567890");
|
||||
await expectPathMissing(path.join(stateDir, "acpx", "codex-acp-wrapper.stderr.log"));
|
||||
});
|
||||
|
||||
it("leaves a custom Claude agent command alone", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
claude: {
|
||||
command: "node ./custom-claude-wrapper.mjs --flag",
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
|
||||
});
|
||||
|
||||
expect(resolved.agents.claude).toBe("node ./custom-claude-wrapper.mjs --flag");
|
||||
});
|
||||
|
||||
it("does not normalize custom Claude commands that only mention the package name", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const command =
|
||||
"node ./custom-claude-wrapper.mjs @agentclientprotocol/claude-agent-acp@0.31.4 --flag";
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
claude: {
|
||||
command,
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
|
||||
});
|
||||
|
||||
expect(resolved.agents.claude).toBe(command);
|
||||
});
|
||||
});
|
||||
770
extensions/acpx/src/codex-auth-bridge.ts
Normal file
770
extensions/acpx/src/codex-auth-bridge.ts
Normal file
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* Prepares isolated Codex and Claude ACP wrapper commands for ACPX. The bridge
|
||||
* copies safe auth/config state into plugin-owned homes and redacts diagnostics.
|
||||
*/
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
|
||||
import {
|
||||
extractTrustedCodexProjectPaths,
|
||||
renderIsolatedCodexConfig,
|
||||
} from "./codex-trust-config.js";
|
||||
import { quoteCommandPart, splitCommandParts } from "./command-line.js";
|
||||
import { resolveAcpxPluginRoot } from "./config.js";
|
||||
import type { ResolvedAcpxPluginConfig } from "./config.js";
|
||||
import {
|
||||
OPENCLAW_ACPX_LEASE_ID_ARG,
|
||||
OPENCLAW_ACPX_LEASE_ID_ENV,
|
||||
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
|
||||
} from "./process-lease.js";
|
||||
|
||||
const CODEX_ACP_PACKAGE = "@zed-industries/codex-acp";
|
||||
const CODEX_ACP_BIN = "codex-acp";
|
||||
const CLAUDE_ACP_PACKAGE = "@agentclientprotocol/claude-agent-acp";
|
||||
const CLAUDE_ACP_BIN = "claude-agent-acp";
|
||||
const RUN_CONFIGURED_COMMAND_SENTINEL = "--openclaw-run-configured";
|
||||
const requireFromHere = createRequire(import.meta.url);
|
||||
|
||||
type PackageManifest = {
|
||||
name?: unknown;
|
||||
bin?: unknown;
|
||||
dependencies?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readSelfManifest(): PackageManifest {
|
||||
const manifestPath = path.join(resolveAcpxPluginRoot(import.meta.url), "package.json");
|
||||
return JSON.parse(fsSync.readFileSync(manifestPath, "utf8")) as PackageManifest;
|
||||
}
|
||||
|
||||
function readManifestDependencyVersion(packageName: string): string {
|
||||
const version = readSelfManifest().dependencies?.[packageName];
|
||||
if (typeof version !== "string" || version.trim() === "") {
|
||||
throw new Error(`Missing ${packageName} dependency version in @openclaw/acpx manifest`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
const CODEX_ACP_PACKAGE_VERSION = readManifestDependencyVersion(CODEX_ACP_PACKAGE);
|
||||
const CLAUDE_ACP_PACKAGE_VERSION = readManifestDependencyVersion(CLAUDE_ACP_PACKAGE);
|
||||
|
||||
function basename(value: string): string {
|
||||
return value.split(/[\\/]/).pop() ?? value;
|
||||
}
|
||||
|
||||
function resolvePackageBinPath(
|
||||
packageJsonPath: string,
|
||||
manifest: PackageManifest,
|
||||
binName: string,
|
||||
): string | undefined {
|
||||
const { bin } = manifest;
|
||||
const relativeBinPath =
|
||||
typeof bin === "string"
|
||||
? bin
|
||||
: bin && typeof bin === "object"
|
||||
? (bin as Record<string, unknown>)[binName]
|
||||
: undefined;
|
||||
if (typeof relativeBinPath !== "string" || relativeBinPath.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
return path.resolve(path.dirname(packageJsonPath), relativeBinPath);
|
||||
}
|
||||
|
||||
async function resolveInstalledAcpPackageBinPath(
|
||||
packageName: string,
|
||||
binName: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const packageJsonPath = requireFromHere.resolve(`${packageName}/package.json`);
|
||||
const { value: manifest } = await readJsonFileWithFallback<PackageManifest>(
|
||||
packageJsonPath,
|
||||
{},
|
||||
);
|
||||
if (manifest.name !== packageName) {
|
||||
return undefined;
|
||||
}
|
||||
const binPath = resolvePackageBinPath(packageJsonPath, manifest, binName);
|
||||
if (!binPath) {
|
||||
return undefined;
|
||||
}
|
||||
await fs.access(binPath);
|
||||
return binPath;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInstalledCodexAcpBinPath(): Promise<string | undefined> {
|
||||
// Keep OpenClaw's isolated CODEX_HOME wrapper, but launch the plugin-local
|
||||
// Codex ACP adapter when the package dependency is available.
|
||||
return await resolveInstalledAcpPackageBinPath(CODEX_ACP_PACKAGE, CODEX_ACP_BIN);
|
||||
}
|
||||
|
||||
async function resolveInstalledClaudeAcpBinPath(): Promise<string | undefined> {
|
||||
return await resolveInstalledAcpPackageBinPath(CLAUDE_ACP_PACKAGE, CLAUDE_ACP_BIN);
|
||||
}
|
||||
|
||||
type DiagnosticRedactionRuleSpec = {
|
||||
source: string;
|
||||
flags: string;
|
||||
replacement: string;
|
||||
};
|
||||
|
||||
const DIAGNOSTIC_REDACTION_RULES: DiagnosticRedactionRuleSpec[] = [
|
||||
{
|
||||
source: String.raw`(authorization\s*[:=]\s*bearer\s+)[^\s'"<>]+`,
|
||||
flags: "gi",
|
||||
replacement: "$1[REDACTED]",
|
||||
},
|
||||
{
|
||||
source: String.raw`((?:api[_-]?key|apiKey|access[_-]?token|refresh[_-]?token|client[_-]?secret|token|secret|password|passwd|credential)\s*[:=]\s*)[^\s'"<>]+`,
|
||||
flags: "gi",
|
||||
replacement: "$1[REDACTED]",
|
||||
},
|
||||
{
|
||||
source: String.raw`("(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*")[^"]+`,
|
||||
flags: "g",
|
||||
replacement: "$1[REDACTED]",
|
||||
},
|
||||
{
|
||||
source: String.raw`(["']?(?:api[-_]?key|apiKey|access[-_]?token|accessToken|refresh[-_]?token|refreshToken|id[-_]?token|idToken|auth[-_]?token|authToken|client[-_]?secret|clientSecret|app[-_]?secret|appSecret|token|secret|password|passwd|credential)["']?\s*[:=]\s*["']?)[^"',}\s<>]+`,
|
||||
flags: "gi",
|
||||
replacement: "$1[REDACTED]",
|
||||
},
|
||||
{
|
||||
source: String.raw`([?&](?:access[-_]?token|auth[-_]?token|refresh[-_]?token|api[-_]?key|client[-_]?secret|token|key|secret|password|pass|passwd|auth|signature)=)[^&\s'"<>]+`,
|
||||
flags: "gi",
|
||||
replacement: "$1[REDACTED]",
|
||||
},
|
||||
{
|
||||
source: String.raw`(--(?:api[-_]?key|token|secret|password|passwd)\s+)[^\s'"]+`,
|
||||
flags: "gi",
|
||||
replacement: "$1[REDACTED]",
|
||||
},
|
||||
{
|
||||
source:
|
||||
String.raw`-----BEGIN [A-Z ]*PRI` +
|
||||
String.raw`VATE KEY-----[\s\S]+?-----END [A-Z ]*PRI` +
|
||||
String.raw`VATE KEY-----`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_PRIVATE_KEY]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(sk-[A-Za-z0-9_-]{8,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_OPENAI_KEY]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(gh[pousr]_[A-Za-z0-9_]{20,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_GITHUB_TOKEN]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(github_pat_[A-Za-z0-9_]{20,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_GITHUB_TOKEN]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(xox[baprs]-[A-Za-z0-9-]{10,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_SLACK_TOKEN]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(gsk_[A-Za-z0-9_-]{10,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_API_KEY]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(AIza[0-9A-Za-z\-_]{20,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_GOOGLE_KEY]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(ya29\.[0-9A-Za-z_\-./+=]{10,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_GOOGLE_TOKEN]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_JWT]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(pplx-[A-Za-z0-9_-]{10,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_API_KEY]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(npm_[A-Za-z0-9]{10,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_NPM_TOKEN]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(LTAI[A-Za-z0-9]{10,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_ACCESS_KEY]",
|
||||
},
|
||||
{ source: String.raw`\b(hf_[A-Za-z0-9]{10,})\b`, flags: "g", replacement: "[REDACTED_API_KEY]" },
|
||||
{
|
||||
source: String.raw`\bbot(\d{6,}:[A-Za-z0-9_-]{20,})\b`,
|
||||
flags: "g",
|
||||
replacement: "bot[REDACTED_TELEGRAM_TOKEN]",
|
||||
},
|
||||
{
|
||||
source: String.raw`\b(\d{6,}:[A-Za-z0-9_-]{20,})\b`,
|
||||
flags: "g",
|
||||
replacement: "[REDACTED_TELEGRAM_TOKEN]",
|
||||
},
|
||||
];
|
||||
|
||||
function renderDiagnosticRedactionRuleSpecs(): string {
|
||||
return JSON.stringify(DIAGNOSTIC_REDACTION_RULES);
|
||||
}
|
||||
|
||||
function buildAdapterWrapperScript(params: {
|
||||
displayName: string;
|
||||
packageSpec: string;
|
||||
binName: string;
|
||||
installedBinPath?: string;
|
||||
envSetup: string;
|
||||
stderrLogFileNamePrefix?: string;
|
||||
}): string {
|
||||
return `#!/usr/bin/env node
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
${params.envSetup}
|
||||
const stderrLogFileNamePrefix = ${params.stderrLogFileNamePrefix ? JSON.stringify(params.stderrLogFileNamePrefix) : "undefined"};
|
||||
const stderrLogMaxChars = 256 * 1024;
|
||||
|
||||
const openClawWrapperArgs = new Set([
|
||||
${quoteCommandPart(OPENCLAW_ACPX_LEASE_ID_ARG)},
|
||||
${quoteCommandPart(OPENCLAW_GATEWAY_INSTANCE_ID_ARG)},
|
||||
]);
|
||||
|
||||
function readOpenClawWrapperArg(args, name) {
|
||||
const index = args.indexOf(name);
|
||||
if (index < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const value = args[index + 1];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function safeDiagnosticFilePart(value) {
|
||||
const sanitized = String(value || "").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120);
|
||||
return sanitized || "pid-" + process.pid;
|
||||
}
|
||||
|
||||
function resolveStderrLogPath(args) {
|
||||
if (!stderrLogFileNamePrefix) {
|
||||
return undefined;
|
||||
}
|
||||
const leaseId =
|
||||
process.env[${JSON.stringify(OPENCLAW_ACPX_LEASE_ID_ENV)}] ||
|
||||
readOpenClawWrapperArg(args, ${quoteCommandPart(OPENCLAW_ACPX_LEASE_ID_ARG)}) ||
|
||||
"pid-" + process.pid;
|
||||
const fileName = stderrLogFileNamePrefix + "." + safeDiagnosticFilePart(leaseId) + ".log";
|
||||
return fileURLToPath(new URL("./" + fileName, import.meta.url));
|
||||
}
|
||||
|
||||
const diagnosticRedactionRules = ${renderDiagnosticRedactionRuleSpecs()}.map((rule) => [
|
||||
new RegExp(rule.source, rule.flags),
|
||||
rule.replacement,
|
||||
]);
|
||||
|
||||
function redactDiagnosticText(text) {
|
||||
let redacted = text;
|
||||
for (const [pattern, replacement] of diagnosticRedactionRules) {
|
||||
redacted = redacted.replace(pattern, replacement);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
let pendingStderrLogText = "";
|
||||
const stderrPrivateKeyEndPattern = /-----END [A-Z ]*PRIVATE KEY-----/;
|
||||
|
||||
function hasUnclosedPrivateKeyBlock(text) {
|
||||
let lastBeginIndex = -1;
|
||||
for (const match of text.matchAll(/-----BEGIN [A-Z ]*PRIVATE KEY-----/g)) {
|
||||
lastBeginIndex = match.index ?? lastBeginIndex;
|
||||
}
|
||||
if (lastBeginIndex === -1) {
|
||||
return -1;
|
||||
}
|
||||
return stderrPrivateKeyEndPattern.test(text.slice(lastBeginIndex)) ? -1 : lastBeginIndex;
|
||||
}
|
||||
|
||||
function writeRedactedStderrLog(text) {
|
||||
if (!stderrLogPath) {
|
||||
return;
|
||||
}
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
appendFileSync(stderrLogPath, redactDiagnosticText(text), "utf8");
|
||||
const current = readFileSync(stderrLogPath, "utf8");
|
||||
if (current.length > stderrLogMaxChars) {
|
||||
writeFileSync(stderrLogPath, current.slice(-stderrLogMaxChars), "utf8");
|
||||
}
|
||||
} catch {
|
||||
// Stderr capture is diagnostic-only; never break the ACP adapter.
|
||||
}
|
||||
}
|
||||
|
||||
function redactIncompletePrivateKeyTail(text) {
|
||||
const unclosedPrivateKeyStart = hasUnclosedPrivateKeyBlock(text);
|
||||
if (unclosedPrivateKeyStart === -1) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, unclosedPrivateKeyStart) + "[REDACTED_PRIVATE_KEY]";
|
||||
}
|
||||
|
||||
function flushFinalizedStderrLogText() {
|
||||
const lastLineBreak = pendingStderrLogText.lastIndexOf("\\n");
|
||||
if (lastLineBreak === -1) {
|
||||
if (pendingStderrLogText.length > stderrLogMaxChars) {
|
||||
pendingStderrLogText = pendingStderrLogText.slice(-stderrLogMaxChars);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let flushEnd = lastLineBreak + 1;
|
||||
const unclosedPrivateKeyStart = hasUnclosedPrivateKeyBlock(
|
||||
pendingStderrLogText.slice(0, flushEnd),
|
||||
);
|
||||
if (unclosedPrivateKeyStart !== -1) {
|
||||
flushEnd = unclosedPrivateKeyStart;
|
||||
}
|
||||
if (flushEnd <= 0) {
|
||||
if (pendingStderrLogText.length > stderrLogMaxChars) {
|
||||
pendingStderrLogText = pendingStderrLogText.slice(-stderrLogMaxChars);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const finalizedText = pendingStderrLogText.slice(0, flushEnd);
|
||||
pendingStderrLogText = pendingStderrLogText.slice(flushEnd);
|
||||
writeRedactedStderrLog(finalizedText);
|
||||
}
|
||||
|
||||
function appendStderrLog(chunk) {
|
||||
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
pendingStderrLogText += text;
|
||||
flushFinalizedStderrLogText();
|
||||
}
|
||||
|
||||
function finishStderrLog() {
|
||||
const text = redactIncompletePrivateKeyTail(pendingStderrLogText);
|
||||
pendingStderrLogText = "";
|
||||
writeRedactedStderrLog(text);
|
||||
}
|
||||
|
||||
function stripOpenClawWrapperArgs(args) {
|
||||
const stripped = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const value = args[index];
|
||||
if (openClawWrapperArgs.has(value)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
stripped.push(value);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
const rawConfiguredArgs = process.argv.slice(2);
|
||||
const stderrLogPath = resolveStderrLogPath(rawConfiguredArgs);
|
||||
|
||||
try {
|
||||
if (stderrLogPath) {
|
||||
writeFileSync(stderrLogPath, "", "utf8");
|
||||
}
|
||||
} catch {
|
||||
// Stderr capture is diagnostic-only; never break the ACP adapter.
|
||||
}
|
||||
|
||||
const configuredArgs = stripOpenClawWrapperArgs(rawConfiguredArgs);
|
||||
|
||||
function resolveNpmCliPath() {
|
||||
const candidate = path.resolve(
|
||||
path.dirname(process.execPath),
|
||||
"..",
|
||||
"lib",
|
||||
"node_modules",
|
||||
"npm",
|
||||
"bin",
|
||||
"npm-cli.js",
|
||||
);
|
||||
return existsSync(candidate) ? candidate : undefined;
|
||||
}
|
||||
|
||||
const npmCliPath = resolveNpmCliPath();
|
||||
const installedBinPath = ${params.installedBinPath ? quoteCommandPart(params.installedBinPath) : "undefined"};
|
||||
let defaultCommand;
|
||||
let defaultArgs;
|
||||
if (installedBinPath) {
|
||||
defaultCommand = process.execPath;
|
||||
defaultArgs = [installedBinPath];
|
||||
} else if (npmCliPath) {
|
||||
defaultCommand = process.execPath;
|
||||
defaultArgs = [npmCliPath, "exec", "--yes", "--package", "${params.packageSpec}", "--", "${params.binName}"];
|
||||
} else {
|
||||
defaultCommand = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
defaultArgs = ["--yes", "--package", "${params.packageSpec}", "--", "${params.binName}"];
|
||||
}
|
||||
const command =
|
||||
configuredArgs[0] === "${RUN_CONFIGURED_COMMAND_SENTINEL}" ? configuredArgs[1] : defaultCommand;
|
||||
const args =
|
||||
configuredArgs[0] === "${RUN_CONFIGURED_COMMAND_SENTINEL}"
|
||||
? configuredArgs.slice(2)
|
||||
: [...defaultArgs, ...configuredArgs];
|
||||
|
||||
if (!command) {
|
||||
console.error("[openclaw] missing configured ${params.displayName} ACP command");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const child = spawn(command, args, {
|
||||
detached: process.platform !== "win32",
|
||||
env,
|
||||
stdio: ["inherit", "inherit", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
appendStderrLog(chunk);
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
|
||||
let forceKillTimer;
|
||||
let orphanCleanupStarted = false;
|
||||
let childExitCode = 1;
|
||||
|
||||
function killChildTree(signal, options = {}) {
|
||||
if (!child.pid || (!options.force && child.killed)) {
|
||||
return;
|
||||
}
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
// The adapter can spawn grandchildren; signaling the process group keeps
|
||||
// the generated wrapper from leaving an ACP tree behind.
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to direct child signaling below.
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
process.once(signal, () => {
|
||||
killChildTree(signal);
|
||||
});
|
||||
}
|
||||
|
||||
const originalParentPid = process.ppid;
|
||||
const parentWatcher =
|
||||
process.platform === "win32"
|
||||
? undefined
|
||||
: setInterval(() => {
|
||||
// Orphan detection: parent PID changed means our original parent died.
|
||||
// The new parent could be PID 1 (init) on bare-metal hosts, OR a
|
||||
// systemd user-session manager, OR a container init, OR a session
|
||||
// leader — depending on environment. Previously this only triggered
|
||||
// on PPID == 1, which missed all systemd-managed deployments and
|
||||
// leaked codex-acp adapter trees on every gateway restart.
|
||||
if (process.ppid === originalParentPid) {
|
||||
return;
|
||||
}
|
||||
if (orphanCleanupStarted) {
|
||||
return;
|
||||
}
|
||||
orphanCleanupStarted = true;
|
||||
if (parentWatcher) {
|
||||
clearInterval(parentWatcher);
|
||||
}
|
||||
killChildTree("SIGTERM");
|
||||
// Keep the wrapper alive long enough for stubborn adapters to receive
|
||||
// a forced fallback signal after SIGTERM.
|
||||
forceKillTimer = setTimeout(() => {
|
||||
killChildTree("SIGKILL", { force: true });
|
||||
childExitCode = 1;
|
||||
}, 1_500);
|
||||
}, 1_000);
|
||||
parentWatcher?.unref?.();
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(\`[openclaw] failed to launch ${params.displayName} ACP wrapper: \${error.message}\`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (parentWatcher) {
|
||||
clearInterval(parentWatcher);
|
||||
}
|
||||
if (orphanCleanupStarted) {
|
||||
return;
|
||||
}
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
}
|
||||
if (code !== null) {
|
||||
childExitCode = code;
|
||||
return;
|
||||
}
|
||||
childExitCode = signal ? 1 : 0;
|
||||
});
|
||||
|
||||
child.on("close", () => {
|
||||
finishStderrLog();
|
||||
process.exit(childExitCode);
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function buildCodexAcpWrapperScript(installedBinPath?: string): string {
|
||||
return buildAdapterWrapperScript({
|
||||
displayName: "Codex",
|
||||
packageSpec: `${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_VERSION}`,
|
||||
binName: CODEX_ACP_BIN,
|
||||
installedBinPath,
|
||||
stderrLogFileNamePrefix: "codex-acp-wrapper.stderr",
|
||||
envSetup: `const codexHome = fileURLToPath(new URL("./codex-home/", import.meta.url));
|
||||
const codexAuthPath = fileURLToPath(new URL("./codex-home/auth.json", import.meta.url));
|
||||
const codexApiKey = (process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY || "").trim();
|
||||
let shouldWriteCodexApiKeyAuth = false;
|
||||
if (codexApiKey) {
|
||||
if (!existsSync(codexAuthPath)) {
|
||||
shouldWriteCodexApiKeyAuth = true;
|
||||
} else {
|
||||
try {
|
||||
const existingCodexAuth = JSON.parse(readFileSync(codexAuthPath, "utf8"));
|
||||
shouldWriteCodexApiKeyAuth =
|
||||
!existingCodexAuth ||
|
||||
typeof existingCodexAuth !== "object" ||
|
||||
typeof existingCodexAuth.OPENAI_API_KEY === "string";
|
||||
} catch {
|
||||
shouldWriteCodexApiKeyAuth = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldWriteCodexApiKeyAuth) {
|
||||
writeFileSync(
|
||||
codexAuthPath,
|
||||
JSON.stringify({
|
||||
OPENAI_API_KEY: codexApiKey,
|
||||
tokens: null,
|
||||
last_refresh: null,
|
||||
}) + "\\n",
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
const env = {
|
||||
...process.env,
|
||||
CODEX_HOME: codexHome,
|
||||
};`,
|
||||
});
|
||||
}
|
||||
|
||||
function buildClaudeAcpWrapperScript(installedBinPath?: string): string {
|
||||
return buildAdapterWrapperScript({
|
||||
displayName: "Claude",
|
||||
// This package is patched in OpenClaw; fallback must not float to an unpatched newer release.
|
||||
packageSpec: `${CLAUDE_ACP_PACKAGE}@${CLAUDE_ACP_PACKAGE_VERSION}`,
|
||||
binName: CLAUDE_ACP_BIN,
|
||||
installedBinPath,
|
||||
envSetup: `const env = {
|
||||
...process.env,
|
||||
};`,
|
||||
});
|
||||
}
|
||||
|
||||
async function readSourceCodexConfig(codexHome: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await fs.readFile(path.join(codexHome, "config.toml"), "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareIsolatedCodexHome(params: {
|
||||
baseDir: string;
|
||||
workspaceDir: string;
|
||||
}): Promise<string> {
|
||||
const sourceCodexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
||||
const sourceConfig = await readSourceCodexConfig(sourceCodexHome);
|
||||
const trustedProjectPaths = [
|
||||
...(sourceConfig ? extractTrustedCodexProjectPaths(sourceConfig) : []),
|
||||
params.workspaceDir,
|
||||
];
|
||||
const codexHome = path.join(params.baseDir, "codex-home");
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(codexHome, "config.toml"),
|
||||
renderIsolatedCodexConfig({
|
||||
sourceConfigToml: sourceConfig,
|
||||
projectPaths: trustedProjectPaths,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return codexHome;
|
||||
}
|
||||
|
||||
async function makeGeneratedWrapperExecutableIfPossible(wrapperPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.chmod(wrapperPath, 0o755);
|
||||
} catch {
|
||||
// The wrapper is invoked via `node wrapper.mjs`; executable mode is only a convenience.
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCodexAcpWrapper(baseDir: string, installedBinPath?: string): Promise<string> {
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
const wrapperPath = path.join(baseDir, "codex-acp-wrapper.mjs");
|
||||
await fs.writeFile(wrapperPath, buildCodexAcpWrapperScript(installedBinPath), {
|
||||
encoding: "utf8",
|
||||
});
|
||||
await makeGeneratedWrapperExecutableIfPossible(wrapperPath);
|
||||
return wrapperPath;
|
||||
}
|
||||
|
||||
async function writeClaudeAcpWrapper(baseDir: string, installedBinPath?: string): Promise<string> {
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
const wrapperPath = path.join(baseDir, "claude-agent-acp-wrapper.mjs");
|
||||
await fs.writeFile(wrapperPath, buildClaudeAcpWrapperScript(installedBinPath), {
|
||||
encoding: "utf8",
|
||||
});
|
||||
await makeGeneratedWrapperExecutableIfPossible(wrapperPath);
|
||||
return wrapperPath;
|
||||
}
|
||||
|
||||
function buildWrapperCommand(wrapperPath: string, args: string[] = []): string {
|
||||
return [process.execPath, wrapperPath, ...args].map(quoteCommandPart).join(" ");
|
||||
}
|
||||
|
||||
function isAcpPackageSpec(value: string, packageName: string): boolean {
|
||||
const escapedPackageName = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`^${escapedPackageName}(?:@.+)?$`, "i").test(value.trim());
|
||||
}
|
||||
|
||||
function isAcpBinName(value: string, binName: string): boolean {
|
||||
const commandName = basename(value);
|
||||
const escapedBinName = binName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`^${escapedBinName}(?:\\.exe|\\.[cm]?js)?$`, "i").test(commandName);
|
||||
}
|
||||
|
||||
function isPackageRunnerCommand(value: string): boolean {
|
||||
return /^(?:npx|npm|pnpm|bunx)(?:\.cmd|\.exe)?$/i.test(basename(value));
|
||||
}
|
||||
|
||||
function extractConfiguredAdapterArgs(params: {
|
||||
configuredCommand?: string;
|
||||
packageName: string;
|
||||
binName: string;
|
||||
}): string[] | undefined {
|
||||
const trimmedConfiguredCommand = params.configuredCommand?.trim();
|
||||
if (!trimmedConfiguredCommand) {
|
||||
return [];
|
||||
}
|
||||
const parts = splitCommandParts(trimmedConfiguredCommand);
|
||||
if (!parts.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const packageIndex = parts.findIndex((part) => isAcpPackageSpec(part, params.packageName));
|
||||
if (packageIndex >= 0) {
|
||||
if (!isPackageRunnerCommand(parts[0] ?? "")) {
|
||||
return undefined;
|
||||
}
|
||||
const afterPackage = parts.slice(packageIndex + 1);
|
||||
if (afterPackage[0] === "--" && isAcpBinName(afterPackage[1] ?? "", params.binName)) {
|
||||
return afterPackage.slice(2);
|
||||
}
|
||||
if (isAcpBinName(afterPackage[0] ?? "", params.binName)) {
|
||||
return afterPackage.slice(1);
|
||||
}
|
||||
return afterPackage[0] === "--" ? afterPackage.slice(1) : afterPackage;
|
||||
}
|
||||
|
||||
if (isAcpBinName(parts[0] ?? "", params.binName)) {
|
||||
return parts.slice(1);
|
||||
}
|
||||
if (basename(parts[0] ?? "") === "node" && isAcpBinName(parts[1] ?? "", params.binName)) {
|
||||
return parts.slice(2);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildCodexAcpWrapperCommand(wrapperPath: string, configuredCommand?: string): string {
|
||||
const configuredAdapterArgs = extractConfiguredAdapterArgs({
|
||||
configuredCommand,
|
||||
packageName: CODEX_ACP_PACKAGE,
|
||||
binName: CODEX_ACP_BIN,
|
||||
});
|
||||
if (configuredAdapterArgs) {
|
||||
return buildWrapperCommand(wrapperPath, configuredAdapterArgs);
|
||||
}
|
||||
return buildWrapperCommand(wrapperPath, [
|
||||
RUN_CONFIGURED_COMMAND_SENTINEL,
|
||||
...splitCommandParts(configuredCommand?.trim() ?? ""),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildClaudeAcpWrapperCommand(wrapperPath: string, configuredCommand?: string): string {
|
||||
const configuredAdapterArgs = extractConfiguredAdapterArgs({
|
||||
configuredCommand,
|
||||
packageName: CLAUDE_ACP_PACKAGE,
|
||||
binName: CLAUDE_ACP_BIN,
|
||||
});
|
||||
if (configuredAdapterArgs) {
|
||||
return buildWrapperCommand(wrapperPath, configuredAdapterArgs);
|
||||
}
|
||||
return configuredCommand?.trim() || buildWrapperCommand(wrapperPath);
|
||||
}
|
||||
|
||||
/** Prepare ACPX agent commands and isolated auth homes for Codex/Claude adapters. */
|
||||
export async function prepareAcpxCodexAuthConfig(params: {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
stateDir: string;
|
||||
logger?: unknown;
|
||||
resolveInstalledCodexAcpBinPath?: () => Promise<string | undefined>;
|
||||
resolveInstalledClaudeAcpBinPath?: () => Promise<string | undefined>;
|
||||
}): Promise<ResolvedAcpxPluginConfig> {
|
||||
void params.logger;
|
||||
const codexBaseDir = path.join(params.stateDir, "acpx");
|
||||
await prepareIsolatedCodexHome({
|
||||
baseDir: codexBaseDir,
|
||||
workspaceDir: params.pluginConfig.cwd,
|
||||
});
|
||||
const installedCodexBinPath = await (
|
||||
params.resolveInstalledCodexAcpBinPath ?? resolveInstalledCodexAcpBinPath
|
||||
)();
|
||||
const installedClaudeBinPath = await (
|
||||
params.resolveInstalledClaudeAcpBinPath ?? resolveInstalledClaudeAcpBinPath
|
||||
)();
|
||||
const wrapperPath = await writeCodexAcpWrapper(codexBaseDir, installedCodexBinPath);
|
||||
const claudeWrapperPath = await writeClaudeAcpWrapper(codexBaseDir, installedClaudeBinPath);
|
||||
const configuredCodexCommand = params.pluginConfig.agents.codex;
|
||||
const configuredClaudeCommand = params.pluginConfig.agents.claude;
|
||||
|
||||
return {
|
||||
...params.pluginConfig,
|
||||
agents: {
|
||||
...params.pluginConfig.agents,
|
||||
codex: buildCodexAcpWrapperCommand(wrapperPath, configuredCodexCommand),
|
||||
claude: buildClaudeAcpWrapperCommand(claudeWrapperPath, configuredClaudeCommand),
|
||||
},
|
||||
};
|
||||
}
|
||||
299
extensions/acpx/src/codex-trust-config.ts
Normal file
299
extensions/acpx/src/codex-trust-config.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Builds isolated Codex config for ACPX sessions. It preserves safe inherited
|
||||
* runtime options while rendering only trusted project entries for the session.
|
||||
*/
|
||||
import path from "node:path";
|
||||
|
||||
function stripTomlComment(line: string): string {
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaping = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const ch = line[index];
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (quote === '"' && ch === "\\") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "#") {
|
||||
return line.slice(0, index);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseTomlString(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
try {
|
||||
return JSON.parse(trimmed) as string;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTomlDottedKey(value: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaping = false;
|
||||
|
||||
for (const ch of value.trim()) {
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (quote === '"' && ch === "\\") {
|
||||
current += ch;
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
current += ch;
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ".") {
|
||||
parts.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.trim()) {
|
||||
parts.push(current.trim());
|
||||
}
|
||||
return parts.map((part) => parseTomlString(part) ?? part);
|
||||
}
|
||||
|
||||
function parseProjectHeader(line: string): string | undefined {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("[") || !trimmed.endsWith("]") || trimmed.startsWith("[[")) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = parseTomlDottedKey(trimmed.slice(1, -1));
|
||||
return parts.length === 2 && parts[0] === "projects" ? parts[1] : undefined;
|
||||
}
|
||||
|
||||
function parseTrustedInlineProjectEntries(value: string): string[] {
|
||||
const trusted: string[] = [];
|
||||
const entryPattern =
|
||||
/(?<key>"(?:\\.|[^"\\])*"|'[^']*'|[A-Za-z0-9_\-/.~:]+)\s*=\s*\{(?<body>[^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g;
|
||||
for (const match of value.matchAll(entryPattern)) {
|
||||
const key = match.groups?.key;
|
||||
const body = match.groups?.body;
|
||||
if (!key || !body || !/\btrust_level\s*=\s*["']trusted["']/.test(body)) {
|
||||
continue;
|
||||
}
|
||||
const projectPath = parseTomlString(key) ?? key.trim();
|
||||
if (projectPath) {
|
||||
trusted.push(projectPath);
|
||||
}
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
|
||||
/** Extract trusted project paths from Codex TOML config. */
|
||||
export function extractTrustedCodexProjectPaths(configToml: string): string[] {
|
||||
const trusted = new Set<string>();
|
||||
let currentProjectPath: string | undefined;
|
||||
let inProjectsTable = false;
|
||||
|
||||
for (const rawLine of configToml.split(/\r?\n/)) {
|
||||
const line = stripTomlComment(rawLine).trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("[")) {
|
||||
currentProjectPath = parseProjectHeader(line);
|
||||
inProjectsTable = line === "[projects]";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentProjectPath && /^trust_level\s*=\s*["']trusted["']\s*$/.test(line)) {
|
||||
trusted.add(currentProjectPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const assignment =
|
||||
/^(?<key>"(?:\\.|[^"\\])*"|'[^']*'|[A-Za-z0-9_\-/.~:]+)\s*=\s*(?<value>.+)$/.exec(line);
|
||||
if (!assignment?.groups) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = parseTomlString(assignment.groups.key) ?? assignment.groups.key;
|
||||
const value = assignment.groups.value.trim();
|
||||
if (inProjectsTable && /^\{.*\}$/.test(value)) {
|
||||
if (/\btrust_level\s*=\s*["']trusted["']/.test(value) && key) {
|
||||
trusted.add(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (key === "projects" || inProjectsTable) {
|
||||
for (const projectPath of parseTrustedInlineProjectEntries(value)) {
|
||||
trusted.add(projectPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(trusted);
|
||||
}
|
||||
|
||||
const INHERITED_TOP_LEVEL_CODEX_CONFIG_KEYS = new Set([
|
||||
"model",
|
||||
"model_provider",
|
||||
"model_reasoning_effort",
|
||||
"sandbox_mode",
|
||||
]);
|
||||
|
||||
const INHERITED_MODEL_PROVIDER_CONFIG_KEYS = new Set([
|
||||
"name",
|
||||
"base_url",
|
||||
"wire_api",
|
||||
"env_key",
|
||||
"env_key_instructions",
|
||||
"requires_openai_auth",
|
||||
"request_max_retries",
|
||||
"stream_max_retries",
|
||||
"stream_idle_timeout_ms",
|
||||
]);
|
||||
|
||||
function parseTableHeader(line: string): string[] | undefined {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("[") || !trimmed.endsWith("]") || trimmed.startsWith("[[")) {
|
||||
return undefined;
|
||||
}
|
||||
return parseTomlDottedKey(trimmed.slice(1, -1));
|
||||
}
|
||||
|
||||
function isInheritedModelProviderTable(parts: string[] | undefined): boolean {
|
||||
return parts?.[0] === "model_providers" && parts.length === 2;
|
||||
}
|
||||
|
||||
function parseTopLevelAssignmentKey(line: string): string | undefined {
|
||||
const assignment = /^(?<key>[A-Za-z0-9_-]+)\s*=\s*(?<value>.+)$/.exec(line);
|
||||
return assignment?.groups?.key;
|
||||
}
|
||||
|
||||
function extractInheritedCodexRuntimeConfig(configToml: string): string {
|
||||
const inheritedLines: string[] = [];
|
||||
let inAnyTable = false;
|
||||
let inInheritedTable = false;
|
||||
let pendingInheritedTableHeader = "";
|
||||
|
||||
function flushInheritedTableHeader(): void {
|
||||
if (!pendingInheritedTableHeader) {
|
||||
return;
|
||||
}
|
||||
if (inheritedLines.length > 0 && inheritedLines[inheritedLines.length - 1] !== "") {
|
||||
inheritedLines.push("");
|
||||
}
|
||||
inheritedLines.push(pendingInheritedTableHeader);
|
||||
pendingInheritedTableHeader = "";
|
||||
}
|
||||
|
||||
for (const rawLine of configToml.split(/\r?\n/)) {
|
||||
const trimmedLine = rawLine.trim();
|
||||
const semanticLine = stripTomlComment(rawLine).trim();
|
||||
|
||||
if (trimmedLine.startsWith("[")) {
|
||||
const tableParts = parseTableHeader(trimmedLine);
|
||||
inAnyTable = true;
|
||||
inInheritedTable = isInheritedModelProviderTable(tableParts);
|
||||
if (inInheritedTable) {
|
||||
pendingInheritedTableHeader = rawLine.trimEnd();
|
||||
} else {
|
||||
pendingInheritedTableHeader = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inInheritedTable) {
|
||||
if (!semanticLine) {
|
||||
continue;
|
||||
}
|
||||
const key = parseTopLevelAssignmentKey(semanticLine);
|
||||
if (!key || !INHERITED_MODEL_PROVIDER_CONFIG_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
flushInheritedTableHeader();
|
||||
inheritedLines.push(rawLine.trimEnd());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inAnyTable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = parseTopLevelAssignmentKey(semanticLine);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
if (!INHERITED_TOP_LEVEL_CODEX_CONFIG_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
inheritedLines.push(rawLine.trimEnd());
|
||||
}
|
||||
|
||||
while (inheritedLines.length > 0 && inheritedLines[inheritedLines.length - 1] === "") {
|
||||
inheritedLines.pop();
|
||||
}
|
||||
return inheritedLines.join("\n");
|
||||
}
|
||||
|
||||
/** Render a session-local Codex config with inherited runtime settings and trust entries. */
|
||||
export function renderIsolatedCodexConfig(params: {
|
||||
sourceConfigToml?: string;
|
||||
projectPaths: string[];
|
||||
}): string {
|
||||
const normalized = Array.from(
|
||||
new Set(
|
||||
params.projectPaths
|
||||
.map((projectPath) => projectPath.trim())
|
||||
.filter(Boolean)
|
||||
.map((projectPath) => path.resolve(projectPath)),
|
||||
),
|
||||
).toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
const inheritedConfig = params.sourceConfigToml
|
||||
? extractInheritedCodexRuntimeConfig(params.sourceConfigToml)
|
||||
: "";
|
||||
|
||||
return [
|
||||
"# Generated by OpenClaw for Codex ACP sessions.",
|
||||
inheritedConfig,
|
||||
...normalized.flatMap((projectPath) => [
|
||||
"",
|
||||
`[projects.${JSON.stringify(projectPath)}]`,
|
||||
'trust_level = "trusted"',
|
||||
]),
|
||||
"",
|
||||
]
|
||||
.filter((line, index, lines) => !(line === "" && lines[index - 1] === ""))
|
||||
.join("\n");
|
||||
}
|
||||
56
extensions/acpx/src/command-line.ts
Normal file
56
extensions/acpx/src/command-line.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Small shell-command helpers for ACPX-launched processes. Splitting supports
|
||||
* simple quoted command strings from config without invoking a shell parser.
|
||||
*/
|
||||
/** Quote one command argument for display or config serialization. */
|
||||
export function quoteCommandPart(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** Split a command string into argv-like parts using simple quote/backslash rules. */
|
||||
export function splitCommandParts(value: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaping = false;
|
||||
|
||||
for (const ch of value) {
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\" && quote !== "'") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (current) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current += "\\";
|
||||
}
|
||||
if (current) {
|
||||
parts.push(current);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
130
extensions/acpx/src/config-schema.ts
Normal file
130
extensions/acpx/src/config-schema.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* ACPX plugin configuration schema and public config types. Runtime setup uses
|
||||
* this file as the single source of truth for validation and defaulting.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
|
||||
/** Permission policy applied to interactive ACPX tool requests. */
|
||||
export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
|
||||
|
||||
const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
|
||||
/** Permission policy applied when ACPX cannot ask a human for approval. */
|
||||
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
|
||||
|
||||
/** Default session timeout for ACPX runtime turns. */
|
||||
export const DEFAULT_ACPX_TIMEOUT_SECONDS = 120;
|
||||
|
||||
/** Raw MCP server command config accepted from plugin configuration. */
|
||||
export type McpServerConfig = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
/** Normalized MCP server config emitted to the ACPX runtime process. */
|
||||
export type AcpxMcpServer = {
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Array<{ name: string; value: string }>;
|
||||
};
|
||||
|
||||
/** User-provided ACPX plugin configuration before defaults are resolved. */
|
||||
export type AcpxPluginConfig = {
|
||||
cwd?: string;
|
||||
stateDir?: string;
|
||||
probeAgent?: string;
|
||||
permissionMode?: AcpxPermissionMode;
|
||||
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
|
||||
pluginToolsMcpBridge?: boolean;
|
||||
openClawToolsMcpBridge?: boolean;
|
||||
strictWindowsCmdWrapper?: boolean;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
agents?: Record<string, { command: string; args?: string[] }>;
|
||||
};
|
||||
|
||||
/** Fully resolved ACPX config consumed by the runtime service. */
|
||||
export type ResolvedAcpxPluginConfig = {
|
||||
cwd: string;
|
||||
stateDir: string;
|
||||
probeAgent?: string;
|
||||
permissionMode: AcpxPermissionMode;
|
||||
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
|
||||
pluginToolsMcpBridge: boolean;
|
||||
openClawToolsMcpBridge: boolean;
|
||||
strictWindowsCmdWrapper: boolean;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds: number;
|
||||
legacyCompatibilityConfig: {
|
||||
strictWindowsCmdWrapper?: boolean;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
};
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
agents: Record<string, string>;
|
||||
};
|
||||
|
||||
const nonEmptyTrimmedString = (message: string) =>
|
||||
z.string({ error: message }).trim().min(1, { error: message });
|
||||
|
||||
const McpServerConfigSchema = z.object({
|
||||
command: nonEmptyTrimmedString("command must be a non-empty string").describe(
|
||||
"Command to run the MCP server",
|
||||
),
|
||||
args: z
|
||||
.array(z.string({ error: "args must be an array of strings" }), {
|
||||
error: "args must be an array of strings",
|
||||
})
|
||||
.optional()
|
||||
.describe("Arguments to pass to the command"),
|
||||
env: z
|
||||
.record(z.string(), z.string({ error: "env values must be strings" }), {
|
||||
error: "env must be an object of strings",
|
||||
})
|
||||
.optional()
|
||||
.describe("Environment variables for the MCP server"),
|
||||
});
|
||||
|
||||
/** Zod schema for validating raw ACPX plugin config from OpenClaw config. */
|
||||
export const AcpxPluginConfigSchema = z.strictObject({
|
||||
cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(),
|
||||
stateDir: nonEmptyTrimmedString("stateDir must be a non-empty string").optional(),
|
||||
probeAgent: nonEmptyTrimmedString("probeAgent must be a non-empty string").optional(),
|
||||
permissionMode: z
|
||||
.enum(ACPX_PERMISSION_MODES, {
|
||||
error: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
|
||||
})
|
||||
.optional(),
|
||||
nonInteractivePermissions: z
|
||||
.enum(ACPX_NON_INTERACTIVE_POLICIES, {
|
||||
error: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
|
||||
})
|
||||
.optional(),
|
||||
pluginToolsMcpBridge: z.boolean({ error: "pluginToolsMcpBridge must be a boolean" }).optional(),
|
||||
openClawToolsMcpBridge: z
|
||||
.boolean({ error: "openClawToolsMcpBridge must be a boolean" })
|
||||
.optional(),
|
||||
strictWindowsCmdWrapper: z
|
||||
.boolean({ error: "strictWindowsCmdWrapper must be a boolean" })
|
||||
.optional(),
|
||||
timeoutSeconds: z
|
||||
.number({ error: "timeoutSeconds must be a number >= 0.001" })
|
||||
.min(0.001, { error: "timeoutSeconds must be a number >= 0.001" })
|
||||
.default(DEFAULT_ACPX_TIMEOUT_SECONDS),
|
||||
queueOwnerTtlSeconds: z
|
||||
.number({ error: "queueOwnerTtlSeconds must be a number >= 0" })
|
||||
.min(0, { error: "queueOwnerTtlSeconds must be a number >= 0" })
|
||||
.optional(),
|
||||
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
||||
agents: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.strictObject({
|
||||
command: nonEmptyTrimmedString("agents.<id>.command must be a non-empty string"),
|
||||
args: z.array(z.string({ error: "args must be an array of strings" })).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
296
extensions/acpx/src/config.test.ts
Normal file
296
extensions/acpx/src/config.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
// ACPX tests cover config plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAcpxPluginConfig, resolveAcpxPluginRoot } from "./config.js";
|
||||
|
||||
const requireFromTest = createRequire(import.meta.url);
|
||||
const TSX_IMPORT = requireFromTest.resolve("tsx");
|
||||
|
||||
function expectedMcpServerArgs(params: { sourceEntry: string; distEntry: string }): string[] {
|
||||
const distEntry = path.resolve(params.distEntry);
|
||||
if (fs.existsSync(distEntry)) {
|
||||
return [distEntry];
|
||||
}
|
||||
return ["--import", TSX_IMPORT, path.resolve(params.sourceEntry)];
|
||||
}
|
||||
|
||||
describe("embedded acpx plugin config", () => {
|
||||
it("resolves workspace stateDir and cwd by default", () => {
|
||||
const workspaceDir = path.resolve("/tmp/openclaw-acpx");
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: undefined,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
expect(resolved.cwd).toBe(workspaceDir);
|
||||
expect(resolved.stateDir).toBe(path.join(workspaceDir, "state"));
|
||||
expect(resolved.permissionMode).toBe("approve-reads");
|
||||
expect(resolved.nonInteractivePermissions).toBe("fail");
|
||||
expect(resolved.timeoutSeconds).toBe(120);
|
||||
expect(resolved.agents).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("keeps explicit timeoutSeconds config", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
timeoutSeconds: 300,
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.timeoutSeconds).toBe(300);
|
||||
});
|
||||
|
||||
it("keeps explicit probeAgent config", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
probeAgent: "claude",
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.probeAgent).toBe("claude");
|
||||
});
|
||||
|
||||
it("accepts agent command overrides", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
claude: { command: "claude --acp" },
|
||||
codex: { command: "codex custom-acp" },
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.agents).toEqual({
|
||||
claude: "claude --acp",
|
||||
codex: "codex custom-acp",
|
||||
});
|
||||
});
|
||||
|
||||
it("combines agent command with args array", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
claude: {
|
||||
command: "node",
|
||||
args: ["/path/to/adapter.mjs", "--verbose"],
|
||||
},
|
||||
codex: {
|
||||
command: "codex-acp",
|
||||
args: ["--model", "gpt-5"],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.agents).toEqual({
|
||||
claude: "node /path/to/adapter.mjs --verbose",
|
||||
codex: "codex-acp --model gpt-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("quotes agent args that need to survive command-line parsing as one token", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
custom: {
|
||||
command: "node",
|
||||
args: ["/tmp/My Adapter.mjs", "--flag=value with spaces", "owner's-choice"],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.agents).toEqual({
|
||||
custom: "node '/tmp/My Adapter.mjs' '--flag=value with spaces' 'owner'\"'\"'s-choice'",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles agent command without args (backward compat)", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
simple: { command: "simple-acp" },
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.agents).toEqual({
|
||||
simple: "simple-acp",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves probeAgent undefined by default so the runtime picks its built-in probe agent", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: undefined,
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.probeAgent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries an explicit probeAgent through to the resolved plugin config, trimmed and lowercased", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
probeAgent: " OpenCode ",
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.probeAgent).toBe("opencode");
|
||||
});
|
||||
|
||||
it("rejects an empty probeAgent string", () => {
|
||||
expect(() =>
|
||||
resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
probeAgent: "",
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
}),
|
||||
).toThrow(/probeAgent must be a non-empty string/);
|
||||
});
|
||||
|
||||
it("injects the built-in plugin-tools MCP server only when explicitly enabled", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
pluginToolsMcpBridge: true,
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
const server = resolved.mcpServers["openclaw-plugin-tools"];
|
||||
expect(server).toEqual({
|
||||
command: process.execPath,
|
||||
args: expectedMcpServerArgs({
|
||||
sourceEntry: "src/mcp/plugin-tools-serve.ts",
|
||||
distEntry: "dist/mcp/plugin-tools-serve.js",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("injects the built-in OpenClaw tools MCP server only when explicitly enabled", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
openClawToolsMcpBridge: true,
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
const server = resolved.mcpServers["openclaw-tools"];
|
||||
expect(server).toEqual({
|
||||
command: process.execPath,
|
||||
args: expectedMcpServerArgs({
|
||||
sourceEntry: "src/mcp/openclaw-tools-serve.ts",
|
||||
distEntry: "dist/mcp/openclaw-tools-serve.js",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the plugin root from shared dist chunk paths", () => {
|
||||
const moduleUrl = new URL("../../../dist/extensions/acpx/service-shared.js", import.meta.url)
|
||||
.href;
|
||||
|
||||
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(path.resolve("extensions/acpx"));
|
||||
});
|
||||
|
||||
it("keeps the runtime json schema in sync with the manifest config schema", () => {
|
||||
const pluginRoot = resolveAcpxPluginRoot();
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "utf8"),
|
||||
) as { configSchema?: unknown };
|
||||
|
||||
expect(manifest.configSchema).toStrictEqual({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
cwd: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
stateDir: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
permissionMode: {
|
||||
type: "string",
|
||||
enum: ["approve-all", "approve-reads", "deny-all"],
|
||||
},
|
||||
nonInteractivePermissions: {
|
||||
type: "string",
|
||||
enum: ["deny", "fail"],
|
||||
},
|
||||
pluginToolsMcpBridge: {
|
||||
type: "boolean",
|
||||
},
|
||||
openClawToolsMcpBridge: {
|
||||
type: "boolean",
|
||||
},
|
||||
strictWindowsCmdWrapper: {
|
||||
type: "boolean",
|
||||
},
|
||||
timeoutSeconds: {
|
||||
type: "number",
|
||||
minimum: 0.001,
|
||||
default: 120,
|
||||
},
|
||||
queueOwnerTtlSeconds: {
|
||||
type: "number",
|
||||
minimum: 0,
|
||||
},
|
||||
probeAgent: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
mcpServers: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
description: "Command to run the MCP server",
|
||||
},
|
||||
args: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Arguments to pass to the command",
|
||||
},
|
||||
env: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
description: "Environment variables for the MCP server",
|
||||
},
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
args: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
290
extensions/acpx/src/config.ts
Normal file
290
extensions/acpx/src/config.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Resolves ACPX plugin config from raw user configuration. It locates the
|
||||
* plugin root, injects optional MCP bridge servers, and applies runtime defaults.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { formatPluginConfigIssue } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { AcpxPluginConfigSchema, DEFAULT_ACPX_TIMEOUT_SECONDS } from "./config-schema.js";
|
||||
import type {
|
||||
AcpxPluginConfig,
|
||||
AcpxPermissionMode,
|
||||
AcpxNonInteractivePermissionPolicy,
|
||||
McpServerConfig,
|
||||
AcpxMcpServer,
|
||||
ResolvedAcpxPluginConfig,
|
||||
} from "./config-schema.js";
|
||||
export { type ResolvedAcpxPluginConfig } from "./config-schema.js";
|
||||
|
||||
const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools";
|
||||
const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
|
||||
const requireFromHere = createRequire(import.meta.url);
|
||||
|
||||
function isAcpxPluginRoot(dir: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(dir, "openclaw.plugin.json")) &&
|
||||
fs.existsSync(path.join(dir, "package.json"))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNearestAcpxPluginRoot(moduleUrl: string): string {
|
||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
// Bundled entries live at the plugin root while source files still live under src/.
|
||||
if (isAcpxPluginRoot(cursor)) {
|
||||
return cursor;
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
||||
}
|
||||
|
||||
function resolveWorkspaceAcpxPluginRoot(currentRoot: string): string | null {
|
||||
if (
|
||||
path.basename(currentRoot) !== "acpx" ||
|
||||
path.basename(path.dirname(currentRoot)) !== "extensions" ||
|
||||
path.basename(path.dirname(path.dirname(currentRoot))) !== "dist"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const workspaceRoot = path.resolve(currentRoot, "..", "..", "..", "extensions", "acpx");
|
||||
return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
|
||||
}
|
||||
|
||||
function resolveRepoAcpxPluginRoot(currentRoot: string): string | null {
|
||||
const workspaceRoot = path.join(currentRoot, "extensions", "acpx");
|
||||
return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
|
||||
}
|
||||
|
||||
function resolveAcpxPluginRootFromOpenClawLayout(moduleUrl: string): string | null {
|
||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const candidates = [
|
||||
path.join(cursor, "extensions", "acpx"),
|
||||
path.join(cursor, "dist", "extensions", "acpx"),
|
||||
path.join(cursor, "dist-runtime", "extensions", "acpx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (isAcpxPluginRoot(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** Resolve the ACPX plugin root across source, dist, and dist-runtime layouts. */
|
||||
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
||||
const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl);
|
||||
// In a live repo checkout, dist/ can be rebuilt out from under the running gateway.
|
||||
// Prefer the stable source plugin root when a built extension is running beside it.
|
||||
return (
|
||||
resolveWorkspaceAcpxPluginRoot(resolvedRoot) ??
|
||||
resolveRepoAcpxPluginRoot(resolvedRoot) ??
|
||||
// Shared dist/dist-runtime chunks can load this module outside the plugin tree.
|
||||
// Scan common OpenClaw layouts before falling back to the nearest path guess.
|
||||
resolveAcpxPluginRootFromOpenClawLayout(moduleUrl) ??
|
||||
resolvedRoot
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
|
||||
const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
|
||||
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
|
||||
const DEFAULT_STRICT_WINDOWS_CMD_WRAPPER = true;
|
||||
|
||||
type ParseResult =
|
||||
| { ok: true; value: AcpxPluginConfig | undefined }
|
||||
| { ok: false; message: string };
|
||||
|
||||
function parseAcpxPluginConfig(value: unknown): ParseResult {
|
||||
if (value === undefined) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
const parsed = AcpxPluginConfigSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, message: formatPluginConfigIssue(parsed.error.issues[0]) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: parsed.data as AcpxPluginConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenClawRoot(currentRoot: string): string {
|
||||
if (
|
||||
path.basename(currentRoot) === "acpx" &&
|
||||
path.basename(path.dirname(currentRoot)) === "extensions"
|
||||
) {
|
||||
const parent = path.dirname(path.dirname(currentRoot));
|
||||
if (path.basename(parent) === "dist") {
|
||||
return path.dirname(parent);
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
return path.resolve(currentRoot, "..");
|
||||
}
|
||||
|
||||
function resolveTsxImportSpecifier(): string {
|
||||
try {
|
||||
return requireFromHere.resolve("tsx");
|
||||
} catch {
|
||||
return "tsx";
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuoteCommandArg(arg: string): string {
|
||||
if (!/[\s'"\\$|&;<>{}()*?[\]~`]/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return `'${arg.replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function resolvePluginToolsMcpServerConfig(moduleUrl: string = import.meta.url): McpServerConfig {
|
||||
const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
|
||||
const openClawRoot = resolveOpenClawRoot(pluginRoot);
|
||||
const distEntry = path.join(openClawRoot, "dist", "mcp", "plugin-tools-serve.js");
|
||||
if (fs.existsSync(distEntry)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [distEntry],
|
||||
};
|
||||
}
|
||||
const sourceEntry = path.join(openClawRoot, "src", "mcp", "plugin-tools-serve.ts");
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenClawToolsMcpServerConfig(moduleUrl: string = import.meta.url): McpServerConfig {
|
||||
const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
|
||||
const openClawRoot = resolveOpenClawRoot(pluginRoot);
|
||||
const distEntry = path.join(openClawRoot, "dist", "mcp", "openclaw-tools-serve.js");
|
||||
if (fs.existsSync(distEntry)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [distEntry],
|
||||
};
|
||||
}
|
||||
const sourceEntry = path.join(openClawRoot, "src", "mcp", "openclaw-tools-serve.ts");
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredMcpServers(params: {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
pluginToolsMcpBridge: boolean;
|
||||
openClawToolsMcpBridge: boolean;
|
||||
moduleUrl?: string;
|
||||
}): Record<string, McpServerConfig> {
|
||||
const resolved = { ...params.mcpServers };
|
||||
if (params.pluginToolsMcpBridge && resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
|
||||
throw new Error(
|
||||
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
|
||||
);
|
||||
}
|
||||
if (params.openClawToolsMcpBridge && resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME]) {
|
||||
throw new Error(
|
||||
`mcpServers.${ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME} is reserved when openClawToolsMcpBridge=true`,
|
||||
);
|
||||
}
|
||||
if (params.pluginToolsMcpBridge) {
|
||||
resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(
|
||||
params.moduleUrl,
|
||||
);
|
||||
}
|
||||
if (params.openClawToolsMcpBridge) {
|
||||
resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME] = resolveOpenClawToolsMcpServerConfig(
|
||||
params.moduleUrl,
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Convert OpenClaw MCP server config into ACPX runtime MCP server entries. */
|
||||
export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): AcpxMcpServer[] {
|
||||
return Object.entries(mcpServers).map(([name, server]) => ({
|
||||
name,
|
||||
command: server.command,
|
||||
args: [...(server.args ?? [])],
|
||||
env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
|
||||
name: envName,
|
||||
value,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Validate and normalize raw ACPX plugin config for runtime startup. */
|
||||
export function resolveAcpxPluginConfig(params: {
|
||||
rawConfig: unknown;
|
||||
workspaceDir?: string;
|
||||
moduleUrl?: string;
|
||||
}): ResolvedAcpxPluginConfig {
|
||||
const parsed = parseAcpxPluginConfig(params.rawConfig);
|
||||
if (!parsed.ok) {
|
||||
throw new Error(parsed.message);
|
||||
}
|
||||
const normalized = parsed.value ?? {};
|
||||
const workspaceDir = params.workspaceDir?.trim() || process.cwd();
|
||||
const fallbackCwd = workspaceDir;
|
||||
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
|
||||
const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state"));
|
||||
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
|
||||
const openClawToolsMcpBridge = normalized.openClawToolsMcpBridge === true;
|
||||
const mcpServers = resolveConfiguredMcpServers({
|
||||
mcpServers: normalized.mcpServers,
|
||||
pluginToolsMcpBridge,
|
||||
openClawToolsMcpBridge,
|
||||
moduleUrl: params.moduleUrl,
|
||||
});
|
||||
const agents = Object.fromEntries(
|
||||
Object.entries(normalized.agents ?? {}).map(([name, entry]) => {
|
||||
const cmd = entry.command.trim();
|
||||
const cmdArgs = entry.args ?? [];
|
||||
const fullCommand =
|
||||
cmdArgs.length > 0 ? `${cmd} ${cmdArgs.map(shellQuoteCommandArg).join(" ")}` : cmd;
|
||||
return [normalizeLowercaseStringOrEmpty(name), fullCommand];
|
||||
}),
|
||||
);
|
||||
|
||||
// Lowercase probeAgent so lookups match the registry keys built above, which
|
||||
// also go through normalizeLowercaseStringOrEmpty. Without this, a user who
|
||||
// writes `probeAgent: "OpenCode"` would silently miss the stored "opencode"
|
||||
// key.
|
||||
const probeAgent = normalizeLowercaseStringOrEmpty(normalized.probeAgent) || undefined;
|
||||
|
||||
return {
|
||||
cwd,
|
||||
stateDir,
|
||||
probeAgent,
|
||||
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
||||
nonInteractivePermissions:
|
||||
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
|
||||
pluginToolsMcpBridge,
|
||||
openClawToolsMcpBridge,
|
||||
strictWindowsCmdWrapper:
|
||||
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
|
||||
timeoutSeconds: normalized.timeoutSeconds ?? DEFAULT_ACPX_TIMEOUT_SECONDS,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
|
||||
legacyCompatibilityConfig: {
|
||||
strictWindowsCmdWrapper: normalized.strictWindowsCmdWrapper,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds,
|
||||
},
|
||||
mcpServers,
|
||||
agents,
|
||||
};
|
||||
}
|
||||
22
extensions/acpx/src/manifest.test.ts
Normal file
22
extensions/acpx/src/manifest.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// ACPX tests cover manifest plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type AcpxPackageManifest = {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
||||
) as AcpxPackageManifest;
|
||||
|
||||
describe("acpx package manifest", () => {
|
||||
it("keeps runtime dependencies in the package manifest", () => {
|
||||
expect(packageJson.dependencies?.acpx).toBeTypeOf("string");
|
||||
expect(packageJson.dependencies?.acpx).not.toBe("");
|
||||
expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.16.0");
|
||||
expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.55.0");
|
||||
expect(packageJson.devDependencies?.["@agentclientprotocol/claude-agent-acp"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
124
extensions/acpx/src/process-lease.test.ts
Normal file
124
extensions/acpx/src/process-lease.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// ACPX tests cover process lease plugin behavior.
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createAcpxProcessLeaseStore,
|
||||
openAcpxProcessLeaseStateStore,
|
||||
OPENCLAW_ACPX_LEASE_ID_ARG,
|
||||
OPENCLAW_ACPX_LEASE_ID_ENV,
|
||||
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
|
||||
OPENCLAW_GATEWAY_INSTANCE_ID_ENV,
|
||||
withAcpxLeaseEnvironment,
|
||||
type AcpxProcessLease,
|
||||
} from "./process-lease.js";
|
||||
|
||||
function makeLease(index: number): AcpxProcessLease {
|
||||
return {
|
||||
leaseId: `lease-${index}`,
|
||||
gatewayInstanceId: "gateway-test",
|
||||
sessionKey: `agent:codex:acp:${index}`,
|
||||
wrapperRoot: "/tmp/openclaw/acpx",
|
||||
wrapperPath: "/tmp/openclaw/acpx/codex-acp-wrapper.mjs",
|
||||
rootPid: 1000 + index,
|
||||
commandHash: `hash-${index}`,
|
||||
startedAt: index,
|
||||
state: "open",
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAcpxProcessLeaseStore", () => {
|
||||
let stateDir = "";
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetPluginStateStoreForTests();
|
||||
stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-acpx-leases-"));
|
||||
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createStore() {
|
||||
return createAcpxProcessLeaseStore({
|
||||
store: openAcpxProcessLeaseStateStore((options) =>
|
||||
createPluginStateKeyedStoreForTests("acpx", { ...options, env }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
it("serializes concurrent lease saves without dropping records", async () => {
|
||||
const store = createStore();
|
||||
await Promise.all(Array.from({ length: 25 }, (_, index) => store.save(makeLease(index))));
|
||||
|
||||
const leases = await store.listOpen("gateway-test");
|
||||
expect(leases.map((lease) => lease.leaseId).toSorted()).toEqual(
|
||||
Array.from({ length: 25 }, (_, index) => `lease-${index}`).toSorted(),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes terminal leases from the live lease namespace", async () => {
|
||||
const store = createStore();
|
||||
const openLease = makeLease(1);
|
||||
const closedLease = makeLease(2);
|
||||
await store.save(openLease);
|
||||
await store.save(closedLease);
|
||||
|
||||
await store.markState(closedLease.leaseId, "closed");
|
||||
|
||||
await expect(store.load(closedLease.leaseId)).resolves.toBeUndefined();
|
||||
await expect(store.listOpen("gateway-test")).resolves.toEqual([openLease]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withAcpxLeaseEnvironment", () => {
|
||||
it("adds lease environment and wrapper args on POSIX", () => {
|
||||
const command = withAcpxLeaseEnvironment({
|
||||
command: "node /tmp/openclaw/acpx/codex-acp-wrapper.mjs",
|
||||
leaseId: "lease-test",
|
||||
gatewayInstanceId: "gateway-test",
|
||||
platform: "darwin",
|
||||
});
|
||||
|
||||
expect(command).toBe(
|
||||
[
|
||||
"env",
|
||||
`${OPENCLAW_ACPX_LEASE_ID_ENV}=lease-test`,
|
||||
`${OPENCLAW_GATEWAY_INSTANCE_ID_ENV}=gateway-test`,
|
||||
"node /tmp/openclaw/acpx/codex-acp-wrapper.mjs",
|
||||
OPENCLAW_ACPX_LEASE_ID_ARG,
|
||||
"lease-test",
|
||||
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
|
||||
"gateway-test",
|
||||
].join(" "),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps Windows logs keyed by lease id with wrapper args", () => {
|
||||
const command = withAcpxLeaseEnvironment({
|
||||
command: "node C:/openclaw/acpx/codex-acp-wrapper.mjs",
|
||||
leaseId: "lease-test",
|
||||
gatewayInstanceId: "gateway-test",
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
expect(command).toBe(
|
||||
[
|
||||
"node C:/openclaw/acpx/codex-acp-wrapper.mjs",
|
||||
OPENCLAW_ACPX_LEASE_ID_ARG,
|
||||
"lease-test",
|
||||
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
|
||||
"gateway-test",
|
||||
].join(" "),
|
||||
);
|
||||
expect(command).not.toContain(`${OPENCLAW_ACPX_LEASE_ID_ENV}=`);
|
||||
expect(command).not.toContain(`${OPENCLAW_GATEWAY_INSTANCE_ID_ENV}=`);
|
||||
});
|
||||
});
|
||||
202
extensions/acpx/src/process-lease.ts
Normal file
202
extensions/acpx/src/process-lease.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Persistent lease store for ACPX wrapper processes. Leases let OpenClaw attach
|
||||
* gateway/session identity to spawned ACP processes and clean them up later.
|
||||
*/
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginStateKeyedStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { ACPX_PROCESS_LEASE_MAX_ENTRIES, ACPX_PROCESS_LEASE_NAMESPACE } from "./state.js";
|
||||
|
||||
/** Environment variable carrying the ACPX process lease id. */
|
||||
export const OPENCLAW_ACPX_LEASE_ID_ENV = "OPENCLAW_ACPX_LEASE_ID";
|
||||
/** Environment variable carrying the owning gateway instance id. */
|
||||
export const OPENCLAW_GATEWAY_INSTANCE_ID_ENV = "OPENCLAW_GATEWAY_INSTANCE_ID";
|
||||
/** CLI argument carrying the ACPX process lease id for platforms without env wrapping. */
|
||||
export const OPENCLAW_ACPX_LEASE_ID_ARG = "--openclaw-acpx-lease-id";
|
||||
/** CLI argument carrying the owning gateway instance id. */
|
||||
export const OPENCLAW_GATEWAY_INSTANCE_ID_ARG = "--openclaw-gateway-instance-id";
|
||||
|
||||
/** Lifecycle state for a tracked ACPX wrapper process. */
|
||||
export type AcpxProcessLeaseState = "open" | "closing" | "closed" | "lost";
|
||||
|
||||
/** Persisted identity and command metadata for one ACPX wrapper process. */
|
||||
export type AcpxProcessLease = {
|
||||
leaseId: string;
|
||||
gatewayInstanceId: string;
|
||||
sessionKey: string;
|
||||
wrapperRoot: string;
|
||||
wrapperPath: string;
|
||||
rootPid: number;
|
||||
processGroupId?: number;
|
||||
commandHash: string;
|
||||
startedAt: number;
|
||||
state: AcpxProcessLeaseState;
|
||||
};
|
||||
|
||||
/** Async lease store used by runtime sessions and cleanup routines. */
|
||||
export type AcpxProcessLeaseStore = {
|
||||
load(leaseId: string): Promise<AcpxProcessLease | undefined>;
|
||||
listOpen(gatewayInstanceId?: string): Promise<AcpxProcessLease[]>;
|
||||
save(lease: AcpxProcessLease): Promise<void>;
|
||||
markState(leaseId: string, state: AcpxProcessLeaseState): Promise<void>;
|
||||
};
|
||||
|
||||
export type AcpxProcessLeaseFile = {
|
||||
version: 1;
|
||||
leases: AcpxProcessLease[];
|
||||
};
|
||||
|
||||
export function normalizeAcpxProcessLease(value: unknown): AcpxProcessLease | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.leaseId !== "string" ||
|
||||
typeof record.gatewayInstanceId !== "string" ||
|
||||
typeof record.sessionKey !== "string" ||
|
||||
typeof record.wrapperRoot !== "string" ||
|
||||
typeof record.wrapperPath !== "string" ||
|
||||
typeof record.rootPid !== "number" ||
|
||||
typeof record.commandHash !== "string" ||
|
||||
typeof record.startedAt !== "number" ||
|
||||
!["open", "closing", "closed", "lost"].includes(String(record.state))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
leaseId: record.leaseId,
|
||||
gatewayInstanceId: record.gatewayInstanceId,
|
||||
sessionKey: record.sessionKey,
|
||||
wrapperRoot: record.wrapperRoot,
|
||||
wrapperPath: record.wrapperPath,
|
||||
rootPid: record.rootPid,
|
||||
...(typeof record.processGroupId === "number" ? { processGroupId: record.processGroupId } : {}),
|
||||
commandHash: record.commandHash,
|
||||
startedAt: record.startedAt,
|
||||
state: record.state as AcpxProcessLeaseState,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAcpxProcessLeaseFile(value: unknown): AcpxProcessLeaseFile {
|
||||
const root =
|
||||
typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||
const leases = Array.isArray(root.leases)
|
||||
? root.leases
|
||||
.map(normalizeAcpxProcessLease)
|
||||
.filter((lease): lease is AcpxProcessLease => Boolean(lease))
|
||||
: [];
|
||||
return { version: 1, leases };
|
||||
}
|
||||
|
||||
export function openAcpxProcessLeaseStateStore(
|
||||
openKeyedStore: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T>,
|
||||
): PluginStateKeyedStore<AcpxProcessLease> {
|
||||
return openKeyedStore<AcpxProcessLease>({
|
||||
namespace: ACPX_PROCESS_LEASE_NAMESPACE,
|
||||
maxEntries: ACPX_PROCESS_LEASE_MAX_ENTRIES,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a serialized SQLite-backed ACPX process lease store. */
|
||||
export function createAcpxProcessLeaseStore(params: {
|
||||
store: PluginStateKeyedStore<AcpxProcessLease>;
|
||||
}): AcpxProcessLeaseStore {
|
||||
let updateQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
async function update(mutator: () => Promise<void>): Promise<void> {
|
||||
const run = updateQueue.then(async () => {
|
||||
await mutator();
|
||||
});
|
||||
updateQueue = run.catch(() => {});
|
||||
await run;
|
||||
}
|
||||
|
||||
async function readCurrent(): Promise<AcpxProcessLease[]> {
|
||||
await updateQueue;
|
||||
const entries = await params.store.entries();
|
||||
return entries
|
||||
.map((entry) => normalizeAcpxProcessLease(entry.value))
|
||||
.filter((lease): lease is AcpxProcessLease => Boolean(lease));
|
||||
}
|
||||
|
||||
return {
|
||||
async load(leaseId) {
|
||||
await updateQueue;
|
||||
return normalizeAcpxProcessLease(await params.store.lookup(leaseId));
|
||||
},
|
||||
async listOpen(gatewayInstanceId) {
|
||||
const leases = await readCurrent();
|
||||
return leases.filter(
|
||||
(lease) =>
|
||||
(lease.state === "open" || lease.state === "closing") &&
|
||||
(!gatewayInstanceId || lease.gatewayInstanceId === gatewayInstanceId),
|
||||
);
|
||||
},
|
||||
async save(lease) {
|
||||
await update(async () => {
|
||||
await params.store.register(lease.leaseId, lease);
|
||||
});
|
||||
},
|
||||
async markState(leaseId, state) {
|
||||
await update(async () => {
|
||||
if (state === "closed" || state === "lost") {
|
||||
await params.store.delete(leaseId);
|
||||
return;
|
||||
}
|
||||
const lease = normalizeAcpxProcessLease(await params.store.lookup(leaseId));
|
||||
if (lease) {
|
||||
await params.store.register(leaseId, { ...lease, state });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a unique lease id for one ACPX wrapper process. */
|
||||
export function createAcpxProcessLeaseId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
/** Hash a wrapper command so process leases can detect command drift. */
|
||||
export function hashAcpxProcessCommand(command: string): string {
|
||||
return createHash("sha256").update(command).digest("hex");
|
||||
}
|
||||
|
||||
function quoteEnvValue(value: string): string {
|
||||
return /^[A-Za-z0-9_./:=@+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function appendAcpxLeaseArgs(params: {
|
||||
command: string;
|
||||
leaseId: string;
|
||||
gatewayInstanceId: string;
|
||||
}): string {
|
||||
return [
|
||||
params.command,
|
||||
OPENCLAW_ACPX_LEASE_ID_ARG,
|
||||
quoteEnvValue(params.leaseId),
|
||||
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
|
||||
quoteEnvValue(params.gatewayInstanceId),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/** Add ACPX lease identity to a command through env vars and portable args. */
|
||||
export function withAcpxLeaseEnvironment(params: {
|
||||
command: string;
|
||||
leaseId: string;
|
||||
gatewayInstanceId: string;
|
||||
platform?: NodeJS.Platform;
|
||||
}): string {
|
||||
if ((params.platform ?? process.platform) === "win32") {
|
||||
return appendAcpxLeaseArgs(params);
|
||||
}
|
||||
return [
|
||||
"env",
|
||||
`${OPENCLAW_ACPX_LEASE_ID_ENV}=${quoteEnvValue(params.leaseId)}`,
|
||||
`${OPENCLAW_GATEWAY_INSTANCE_ID_ENV}=${quoteEnvValue(params.gatewayInstanceId)}`,
|
||||
appendAcpxLeaseArgs(params),
|
||||
].join(" ");
|
||||
}
|
||||
337
extensions/acpx/src/process-reaper.test.ts
Normal file
337
extensions/acpx/src/process-reaper.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
// ACPX tests cover process reaper plugin behavior.
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
|
||||
import {
|
||||
cleanupOpenClawOwnedAcpxProcessTree,
|
||||
isOpenClawLeaseAwareAcpxProcessCommand,
|
||||
isOpenClawOwnedAcpxProcessCommand,
|
||||
reapStaleOpenClawOwnedAcpxOrphans,
|
||||
type AcpxProcessInfo,
|
||||
} from "./process-reaper.js";
|
||||
|
||||
const WRAPPER_ROOT = "/tmp/openclaw-state/acpx";
|
||||
const CODEX_WRAPPER_COMMAND = `node ${WRAPPER_ROOT}/codex-acp-wrapper.mjs`;
|
||||
const CODEX_WRAPPER_COMMAND_WITH_LEASE = `${CODEX_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-1 ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-1`;
|
||||
const CLAUDE_WRAPPER_COMMAND = `node ${WRAPPER_ROOT}/claude-agent-acp-wrapper.mjs`;
|
||||
const PLUGIN_DEPS_CODEX_COMMAND =
|
||||
"node /tmp/openclaw/plugin-runtime-deps/node_modules/@zed-industries/codex-acp/bin/codex-acp.js";
|
||||
const LOCAL_NODE_MODULES_CODEX_COMMAND = `node ${path.resolve(
|
||||
"node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
|
||||
)}`;
|
||||
const LOCAL_NODE_MODULES_CODEX_PLATFORM_COMMAND = path.resolve(
|
||||
"node_modules/@zed-industries/codex-acp-linux-x64/bin/codex-acp",
|
||||
);
|
||||
|
||||
function cleanupDeps(processes: AcpxProcessInfo[]) {
|
||||
const killed: Array<{ pid: number; signal: NodeJS.Signals }> = [];
|
||||
return {
|
||||
killed,
|
||||
deps: {
|
||||
listProcesses: vi.fn(async () => processes),
|
||||
killProcess: vi.fn((pid: number, signal: NodeJS.Signals) => {
|
||||
killed.push({ pid, signal });
|
||||
}),
|
||||
sleep: vi.fn(async () => {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectMatching<T, U>(
|
||||
items: readonly T[],
|
||||
predicate: (item: T) => boolean,
|
||||
map: (item: T) => U,
|
||||
): U[] {
|
||||
const matches: U[] = [];
|
||||
for (const item of items) {
|
||||
if (predicate(item)) {
|
||||
matches.push(map(item));
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
describe("process reaper", () => {
|
||||
it("recognizes generated Codex and Claude wrappers only under the configured root", () => {
|
||||
expect(
|
||||
isOpenClawOwnedAcpxProcessCommand({
|
||||
command: CODEX_WRAPPER_COMMAND,
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isOpenClawOwnedAcpxProcessCommand({
|
||||
command: CLAUDE_WRAPPER_COMMAND,
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isOpenClawOwnedAcpxProcessCommand({
|
||||
command: "node /tmp/other/codex-acp-wrapper.mjs",
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("only treats generated wrappers as launch-lease aware", () => {
|
||||
expect(
|
||||
isOpenClawLeaseAwareAcpxProcessCommand({
|
||||
command: CODEX_WRAPPER_COMMAND,
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isOpenClawLeaseAwareAcpxProcessCommand({ command: LOCAL_NODE_MODULES_CODEX_COMMAND }),
|
||||
).toBe(false);
|
||||
expect(isOpenClawLeaseAwareAcpxProcessCommand({ command: PLUGIN_DEPS_CODEX_COMMAND })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes OpenClaw plugin-runtime-deps ACP adapter children", () => {
|
||||
expect(isOpenClawOwnedAcpxProcessCommand({ command: PLUGIN_DEPS_CODEX_COMMAND })).toBe(true);
|
||||
expect(isOpenClawOwnedAcpxProcessCommand({ command: "npx @zed-industries/codex-acp" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes plugin-local ACP adapter package paths without trusting arbitrary installs", () => {
|
||||
expect(isOpenClawOwnedAcpxProcessCommand({ command: LOCAL_NODE_MODULES_CODEX_COMMAND })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isOpenClawOwnedAcpxProcessCommand({
|
||||
command: "node /tmp/other-project/node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("kills an owned recorded process tree children first", async () => {
|
||||
const { deps, killed } = cleanupDeps([
|
||||
{ pid: 100, ppid: 1, command: CODEX_WRAPPER_COMMAND },
|
||||
{ pid: 101, ppid: 100, command: PLUGIN_DEPS_CODEX_COMMAND },
|
||||
{ pid: 102, ppid: 101, command: "node child.js" },
|
||||
]);
|
||||
|
||||
const result = await cleanupOpenClawOwnedAcpxProcessTree({
|
||||
rootPid: 100,
|
||||
rootCommand: CODEX_WRAPPER_COMMAND,
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.skippedReason).toBeUndefined();
|
||||
expect(result.inspectedPids).toEqual([100, 101, 102]);
|
||||
expect(killed.slice(0, 3)).toEqual([
|
||||
{ pid: 102, signal: "SIGTERM" },
|
||||
{ pid: 101, signal: "SIGTERM" },
|
||||
{ pid: 100, signal: "SIGTERM" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows wrapper-root verification when stored wrapper commands are shell-quoted", async () => {
|
||||
const { deps, killed } = cleanupDeps([{ pid: 110, ppid: 1, command: CODEX_WRAPPER_COMMAND }]);
|
||||
|
||||
const result = await cleanupOpenClawOwnedAcpxProcessTree({
|
||||
rootPid: 110,
|
||||
rootCommand: `"/usr/local/bin/node" "${WRAPPER_ROOT}/codex-acp-wrapper.mjs"`,
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.skippedReason).toBeUndefined();
|
||||
expect(killed[0]).toEqual({ pid: 110, signal: "SIGTERM" });
|
||||
});
|
||||
|
||||
it("requires matching lease identity before killing a leased process tree", async () => {
|
||||
const { deps, killed } = cleanupDeps([
|
||||
{ pid: 112, ppid: 1, command: CODEX_WRAPPER_COMMAND_WITH_LEASE },
|
||||
]);
|
||||
|
||||
const result = await cleanupOpenClawOwnedAcpxProcessTree({
|
||||
rootPid: 112,
|
||||
rootCommand: CODEX_WRAPPER_COMMAND,
|
||||
expectedLeaseId: "lease-1",
|
||||
expectedGatewayInstanceId: "gateway-1",
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.skippedReason).toBeUndefined();
|
||||
expect(killed[0]).toEqual({ pid: 112, signal: "SIGTERM" });
|
||||
});
|
||||
|
||||
it("does not kill a reused same-root wrapper pid with a different lease identity", async () => {
|
||||
const { deps, killed } = cleanupDeps([
|
||||
{
|
||||
pid: 113,
|
||||
ppid: 1,
|
||||
command: `${CODEX_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} other-lease ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-1`,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cleanupOpenClawOwnedAcpxProcessTree({
|
||||
rootPid: 113,
|
||||
rootCommand: CODEX_WRAPPER_COMMAND,
|
||||
expectedLeaseId: "lease-1",
|
||||
expectedGatewayInstanceId: "gateway-1",
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
inspectedPids: [113],
|
||||
terminatedPids: [],
|
||||
skippedReason: "not-openclaw-owned",
|
||||
});
|
||||
expect(killed).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("skips recorded pid cleanup when process listing is unavailable", async () => {
|
||||
const killed: Array<{ pid: number; signal: NodeJS.Signals }> = [];
|
||||
const result = await cleanupOpenClawOwnedAcpxProcessTree({
|
||||
rootPid: 200,
|
||||
rootCommand: CODEX_WRAPPER_COMMAND,
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps: {
|
||||
listProcesses: vi.fn(async () => {
|
||||
throw new Error("ps unavailable");
|
||||
}),
|
||||
killProcess: vi.fn((pid, signal) => {
|
||||
killed.push({ pid, signal });
|
||||
}),
|
||||
sleep: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
inspectedPids: [],
|
||||
terminatedPids: [],
|
||||
skippedReason: "unverified-root",
|
||||
});
|
||||
expect(killed).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not kill a reused pid when the live command is not OpenClaw-owned", async () => {
|
||||
const { deps, killed } = cleanupDeps([{ pid: 250, ppid: 1, command: "node unrelated.js" }]);
|
||||
|
||||
const result = await cleanupOpenClawOwnedAcpxProcessTree({
|
||||
rootPid: 250,
|
||||
rootCommand: CODEX_WRAPPER_COMMAND,
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
inspectedPids: [250],
|
||||
terminatedPids: [],
|
||||
skippedReason: "not-openclaw-owned",
|
||||
});
|
||||
expect(killed).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not kill a reused adapter pid when the stored root was a generated wrapper", async () => {
|
||||
const { deps, killed } = cleanupDeps([
|
||||
{
|
||||
pid: 260,
|
||||
ppid: 1,
|
||||
command: PLUGIN_DEPS_CODEX_COMMAND,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cleanupOpenClawOwnedAcpxProcessTree({
|
||||
rootPid: 260,
|
||||
rootCommand: CODEX_WRAPPER_COMMAND,
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
inspectedPids: [260],
|
||||
terminatedPids: [],
|
||||
skippedReason: "not-openclaw-owned",
|
||||
});
|
||||
expect(killed).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("skips non-owned recorded process trees", async () => {
|
||||
const { deps, killed } = cleanupDeps([{ pid: 300, ppid: 1, command: "node server.js" }]);
|
||||
|
||||
const result = await cleanupOpenClawOwnedAcpxProcessTree({
|
||||
rootPid: 300,
|
||||
rootCommand: "node server.js",
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.skippedReason).toBe("not-openclaw-owned");
|
||||
expect(killed).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("reaps stale OpenClaw-owned wrapper and adapter orphans on startup", async () => {
|
||||
const { deps, killed } = cleanupDeps([
|
||||
{ pid: 400, ppid: 1, command: CODEX_WRAPPER_COMMAND },
|
||||
{ pid: 401, ppid: 400, command: PLUGIN_DEPS_CODEX_COMMAND },
|
||||
{ pid: 402, ppid: 401, command: "node child.js" },
|
||||
{ pid: 403, ppid: 1, command: CLAUDE_WRAPPER_COMMAND },
|
||||
{ pid: 404, ppid: 403, command: "node claude-child.js" },
|
||||
{ pid: 405, ppid: 1, command: PLUGIN_DEPS_CODEX_COMMAND },
|
||||
{ pid: 406, ppid: 1, command: "node /tmp/other/codex-acp-wrapper.mjs" },
|
||||
]);
|
||||
|
||||
const result = await reapStaleOpenClawOwnedAcpxOrphans({
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.skippedReason).toBeUndefined();
|
||||
expect(result.inspectedPids).toEqual([400, 401, 402, 403, 404, 405]);
|
||||
expect(
|
||||
collectMatching(
|
||||
killed,
|
||||
(entry) => entry.signal === "SIGTERM",
|
||||
(entry) => entry.pid,
|
||||
),
|
||||
).toEqual([402, 401, 400, 404, 403, 405]);
|
||||
});
|
||||
|
||||
it("reaps plugin-local Codex ACP adapter orphans when the generated wrapper is already gone", async () => {
|
||||
const { deps, killed } = cleanupDeps([
|
||||
{ pid: 500, ppid: 1, command: LOCAL_NODE_MODULES_CODEX_COMMAND },
|
||||
{ pid: 501, ppid: 500, command: LOCAL_NODE_MODULES_CODEX_PLATFORM_COMMAND },
|
||||
]);
|
||||
|
||||
const result = await reapStaleOpenClawOwnedAcpxOrphans({
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(result.skippedReason).toBeUndefined();
|
||||
expect(result.inspectedPids).toEqual([500, 501]);
|
||||
expect(
|
||||
collectMatching(
|
||||
killed,
|
||||
(entry) => entry.signal === "SIGTERM",
|
||||
(entry) => entry.pid,
|
||||
),
|
||||
).toEqual([501, 500]);
|
||||
});
|
||||
|
||||
it("keeps startup scans quiet when process listing is unavailable", async () => {
|
||||
const result = await reapStaleOpenClawOwnedAcpxOrphans({
|
||||
wrapperRoot: WRAPPER_ROOT,
|
||||
deps: {
|
||||
listProcesses: vi.fn(async () => {
|
||||
throw new Error("ps unavailable");
|
||||
}),
|
||||
sleep: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
inspectedPids: [],
|
||||
terminatedPids: [],
|
||||
skippedReason: "process-list-unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
427
extensions/acpx/src/process-reaper.ts
Normal file
427
extensions/acpx/src/process-reaper.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* ACPX process ownership checks and cleanup. The reaper only terminates
|
||||
* OpenClaw-owned wrapper trees after validating paths, packages, and lease ids.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { splitCommandParts } from "./command-line.js";
|
||||
import { resolveAcpxPluginRoot } from "./config.js";
|
||||
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const requireFromHere = createRequire(import.meta.url);
|
||||
const GENERATED_WRAPPER_BASENAMES = new Set([
|
||||
"codex-acp-wrapper.mjs",
|
||||
"claude-agent-acp-wrapper.mjs",
|
||||
]);
|
||||
const OPENCLAW_PLUGIN_DEPS_MARKER = "/plugin-runtime-deps/";
|
||||
const OWNED_ACP_PACKAGE_NAMES = [
|
||||
"@zed-industries/codex-acp",
|
||||
"@zed-industries/codex-acp-darwin-arm64",
|
||||
"@zed-industries/codex-acp-darwin-x64",
|
||||
"@zed-industries/codex-acp-linux-arm64",
|
||||
"@zed-industries/codex-acp-linux-x64",
|
||||
"@zed-industries/codex-acp-win32-arm64",
|
||||
"@zed-industries/codex-acp-win32-x64",
|
||||
"@agentclientprotocol/claude-agent-acp",
|
||||
"acpx",
|
||||
];
|
||||
const ACP_PACKAGE_MARKERS = [
|
||||
...OWNED_ACP_PACKAGE_NAMES.map((packageName) => `/node_modules/${packageName}/`),
|
||||
"/acpx/dist/",
|
||||
];
|
||||
|
||||
/** Minimal process-table row used by ACPX cleanup. */
|
||||
export type AcpxProcessInfo = {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
command: string;
|
||||
};
|
||||
|
||||
/** Injectable process-listing and termination hooks for tests. */
|
||||
export type AcpxProcessCleanupDeps = {
|
||||
listProcesses?: () => Promise<AcpxProcessInfo[]>;
|
||||
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
};
|
||||
|
||||
/** Result from cleaning up a single ACPX process tree. */
|
||||
export type AcpxProcessCleanupResult = {
|
||||
inspectedPids: number[];
|
||||
terminatedPids: number[];
|
||||
skippedReason?: "missing-root" | "not-openclaw-owned" | "unverified-root";
|
||||
};
|
||||
|
||||
/** Result from startup orphan reaping. */
|
||||
export type AcpxStartupReapResult = {
|
||||
inspectedPids: number[];
|
||||
terminatedPids: number[];
|
||||
skippedReason?: "unsupported-platform" | "process-list-unavailable";
|
||||
};
|
||||
|
||||
function normalizePathLike(value: string): string {
|
||||
return value.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function resolvePackageRoot(packageName: string): string | undefined {
|
||||
try {
|
||||
return normalizePathLike(path.dirname(requireFromHere.resolve(`${packageName}/package.json`)));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOpenClawInstallRoot(pluginRoot: string): string {
|
||||
if (
|
||||
path.basename(pluginRoot) === "acpx" &&
|
||||
path.basename(path.dirname(pluginRoot)) === "extensions"
|
||||
) {
|
||||
const parent = path.dirname(path.dirname(pluginRoot));
|
||||
return path.basename(parent) === "dist" ? path.dirname(parent) : parent;
|
||||
}
|
||||
return path.resolve(pluginRoot, "..");
|
||||
}
|
||||
|
||||
function resolveOwnedAcpPackageRootCandidates(packageName: string): string[] {
|
||||
const pluginRoot = resolveAcpxPluginRoot(import.meta.url);
|
||||
const openClawRoot = resolveOpenClawInstallRoot(pluginRoot);
|
||||
return [
|
||||
resolvePackageRoot(packageName),
|
||||
path.join(pluginRoot, "node_modules", packageName),
|
||||
path.join(openClawRoot, "node_modules", packageName),
|
||||
].flatMap((root) => (root ? [normalizePathLike(root)] : []));
|
||||
}
|
||||
|
||||
const OWNED_ACP_PACKAGE_ROOTS = Array.from(
|
||||
new Set(OWNED_ACP_PACKAGE_NAMES.flatMap(resolveOwnedAcpPackageRootCandidates)),
|
||||
);
|
||||
|
||||
function commandBelongsToResolvedAcpPackage(command: string): boolean {
|
||||
return OWNED_ACP_PACKAGE_ROOTS.some((root) => command.includes(`${root}/`));
|
||||
}
|
||||
|
||||
function commandMentionsGeneratedWrapper(command: string): boolean {
|
||||
return Array.from(GENERATED_WRAPPER_BASENAMES).some((basename) => command.includes(basename));
|
||||
}
|
||||
|
||||
function commandWrapperBelongsToRoot(command: string, wrapperRoot: string | undefined): boolean {
|
||||
if (!wrapperRoot) {
|
||||
return true;
|
||||
}
|
||||
const normalizedCommand = normalizePathLike(command);
|
||||
const normalizedRoot = normalizePathLike(wrapperRoot).replace(/\/+$/, "");
|
||||
return Array.from(GENERATED_WRAPPER_BASENAMES).some((basename) =>
|
||||
normalizedCommand.includes(`${normalizedRoot}/${basename}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Check whether a command references an OpenClaw-generated ACPX wrapper path. */
|
||||
export function isOpenClawLeaseAwareAcpxProcessCommand(params: {
|
||||
command: string | undefined;
|
||||
wrapperRoot?: string;
|
||||
}): boolean {
|
||||
const command = params.command?.trim();
|
||||
if (!command) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizePathLike(command);
|
||||
return (
|
||||
commandMentionsGeneratedWrapper(normalized) &&
|
||||
commandWrapperBelongsToRoot(normalized, params.wrapperRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function commandsReferToSameRootCommand(liveCommand: string, storedCommand: string | undefined) {
|
||||
if (!storedCommand?.trim()) {
|
||||
return true;
|
||||
}
|
||||
return normalizePathLike(liveCommand).trim() === normalizePathLike(storedCommand).trim();
|
||||
}
|
||||
|
||||
function commandOptionEquals(
|
||||
parts: string[],
|
||||
option: string,
|
||||
expected: string | undefined,
|
||||
): boolean {
|
||||
if (!expected) {
|
||||
return true;
|
||||
}
|
||||
const index = parts.indexOf(option);
|
||||
return index >= 0 && parts[index + 1] === expected;
|
||||
}
|
||||
|
||||
function liveCommandMatchesLeaseIdentity(params: {
|
||||
command: string | undefined;
|
||||
expectedLeaseId?: string;
|
||||
expectedGatewayInstanceId?: string;
|
||||
}): boolean {
|
||||
if (!params.expectedLeaseId && !params.expectedGatewayInstanceId) {
|
||||
return true;
|
||||
}
|
||||
const parts = splitCommandParts(params.command ?? "");
|
||||
return (
|
||||
commandOptionEquals(parts, OPENCLAW_ACPX_LEASE_ID_ARG, params.expectedLeaseId) &&
|
||||
commandOptionEquals(parts, OPENCLAW_GATEWAY_INSTANCE_ID_ARG, params.expectedGatewayInstanceId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Check whether a command is owned by OpenClaw ACPX runtime packages or wrappers. */
|
||||
export function isOpenClawOwnedAcpxProcessCommand(params: {
|
||||
command: string | undefined;
|
||||
wrapperRoot?: string;
|
||||
}): boolean {
|
||||
const command = params.command?.trim();
|
||||
if (!command) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizePathLike(command);
|
||||
if (
|
||||
isOpenClawLeaseAwareAcpxProcessCommand({
|
||||
command: normalized,
|
||||
wrapperRoot: params.wrapperRoot,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (commandBelongsToResolvedAcpPackage(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (!normalized.includes(OPENCLAW_PLUGIN_DEPS_MARKER)) {
|
||||
return false;
|
||||
}
|
||||
return ACP_PACKAGE_MARKERS.some((marker) => normalized.includes(marker));
|
||||
}
|
||||
|
||||
function parseProcessList(stdout: string): AcpxProcessInfo[] {
|
||||
const processes: AcpxProcessInfo[] = [];
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const match = /^\s*(?<pid>\d+)\s+(?<ppid>\d+)\s+(?<command>.+?)\s*$/.exec(line);
|
||||
if (!match?.groups) {
|
||||
continue;
|
||||
}
|
||||
processes.push({
|
||||
pid: Number.parseInt(match.groups.pid, 10),
|
||||
ppid: Number.parseInt(match.groups.ppid, 10),
|
||||
command: match.groups.command,
|
||||
});
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
/** List host processes in the compact shape needed by ACPX cleanup. */
|
||||
export async function listPlatformProcesses(): Promise<AcpxProcessInfo[]> {
|
||||
if (process.platform === "win32") {
|
||||
return [];
|
||||
}
|
||||
const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="], {
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
return parseProcessList(stdout);
|
||||
}
|
||||
|
||||
function collectProcessTree(processes: AcpxProcessInfo[], rootPid: number): AcpxProcessInfo[] {
|
||||
const childrenByParent = new Map<number, AcpxProcessInfo[]>();
|
||||
for (const processInfo of processes) {
|
||||
const children = childrenByParent.get(processInfo.ppid) ?? [];
|
||||
children.push(processInfo);
|
||||
childrenByParent.set(processInfo.ppid, children);
|
||||
}
|
||||
|
||||
const byPid = new Map(processes.map((processInfo) => [processInfo.pid, processInfo]));
|
||||
const root = byPid.get(rootPid);
|
||||
const collected: AcpxProcessInfo[] = [];
|
||||
if (root) {
|
||||
collected.push(root);
|
||||
}
|
||||
|
||||
const queue = [...(childrenByParent.get(rootPid) ?? [])];
|
||||
while (queue.length > 0) {
|
||||
const next = queue.shift();
|
||||
if (!next || collected.some((processInfo) => processInfo.pid === next.pid)) {
|
||||
continue;
|
||||
}
|
||||
collected.push(next);
|
||||
queue.push(...(childrenByParent.get(next.pid) ?? []));
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function uniquePids(processes: AcpxProcessInfo[]): number[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
processes
|
||||
.map((processInfo) => processInfo.pid)
|
||||
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function terminatePids(
|
||||
pids: number[],
|
||||
deps: AcpxProcessCleanupDeps | undefined,
|
||||
): Promise<number[]> {
|
||||
const killProcess = deps?.killProcess ?? ((pid, signal) => process.kill(pid, signal));
|
||||
const sleep =
|
||||
deps?.sleep ??
|
||||
((ms) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
}));
|
||||
const terminated: number[] = [];
|
||||
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
killProcess(pid, "SIGTERM");
|
||||
terminated.push(pid);
|
||||
} catch {
|
||||
// The process may already be gone.
|
||||
}
|
||||
}
|
||||
if (terminated.length === 0) {
|
||||
return terminated;
|
||||
}
|
||||
await sleep(750);
|
||||
for (const pid of terminated) {
|
||||
if (deps?.killProcess || isProcessAlive(pid)) {
|
||||
try {
|
||||
killProcess(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
}
|
||||
return terminated;
|
||||
}
|
||||
|
||||
/** Terminate one validated OpenClaw-owned ACPX wrapper process tree. */
|
||||
export async function cleanupOpenClawOwnedAcpxProcessTree(params: {
|
||||
rootPid?: number;
|
||||
rootCommand?: string;
|
||||
expectedLeaseId?: string;
|
||||
expectedGatewayInstanceId?: string;
|
||||
wrapperRoot?: string;
|
||||
deps?: AcpxProcessCleanupDeps;
|
||||
}): Promise<AcpxProcessCleanupResult> {
|
||||
const rootPid = params.rootPid;
|
||||
if (!rootPid || rootPid <= 0 || rootPid === process.pid) {
|
||||
return { inspectedPids: [], terminatedPids: [], skippedReason: "missing-root" };
|
||||
}
|
||||
|
||||
let processes: AcpxProcessInfo[];
|
||||
try {
|
||||
processes = await (params.deps?.listProcesses ?? listPlatformProcesses)();
|
||||
} catch {
|
||||
processes = [];
|
||||
}
|
||||
|
||||
const listedTree = collectProcessTree(processes, rootPid);
|
||||
// Session-store PIDs are stale data. If the live process table cannot prove
|
||||
// that this PID still belongs to an OpenClaw-owned wrapper, fail closed to
|
||||
// avoid killing an unrelated process after PID reuse.
|
||||
if (listedTree.length === 0) {
|
||||
return { inspectedPids: [], terminatedPids: [], skippedReason: "unverified-root" };
|
||||
}
|
||||
const rootCommand = listedTree[0]?.command ?? params.rootCommand;
|
||||
const liveCommandWasGeneratedWrapper = commandMentionsGeneratedWrapper(
|
||||
normalizePathLike(rootCommand ?? ""),
|
||||
);
|
||||
const storedCommandWasGeneratedWrapper = commandMentionsGeneratedWrapper(
|
||||
normalizePathLike(params.rootCommand ?? ""),
|
||||
);
|
||||
if (!liveCommandWasGeneratedWrapper && storedCommandWasGeneratedWrapper) {
|
||||
return {
|
||||
inspectedPids: listedTree.map((processInfo) => processInfo.pid),
|
||||
terminatedPids: [],
|
||||
skippedReason: "not-openclaw-owned",
|
||||
};
|
||||
}
|
||||
if (
|
||||
!liveCommandWasGeneratedWrapper &&
|
||||
!commandsReferToSameRootCommand(rootCommand ?? "", params.rootCommand)
|
||||
) {
|
||||
return {
|
||||
inspectedPids: listedTree.map((processInfo) => processInfo.pid),
|
||||
terminatedPids: [],
|
||||
skippedReason: "not-openclaw-owned",
|
||||
};
|
||||
}
|
||||
if (
|
||||
!isOpenClawOwnedAcpxProcessCommand({
|
||||
command: rootCommand,
|
||||
wrapperRoot: params.wrapperRoot,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
inspectedPids: listedTree.map((processInfo) => processInfo.pid),
|
||||
terminatedPids: [],
|
||||
skippedReason: "not-openclaw-owned",
|
||||
};
|
||||
}
|
||||
if (
|
||||
!liveCommandMatchesLeaseIdentity({
|
||||
command: rootCommand,
|
||||
expectedLeaseId: params.expectedLeaseId,
|
||||
expectedGatewayInstanceId: params.expectedGatewayInstanceId,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
inspectedPids: listedTree.map((processInfo) => processInfo.pid),
|
||||
terminatedPids: [],
|
||||
skippedReason: "not-openclaw-owned",
|
||||
};
|
||||
}
|
||||
|
||||
const pids = uniquePids(listedTree.toReversed());
|
||||
return {
|
||||
inspectedPids: uniquePids(listedTree),
|
||||
terminatedPids: await terminatePids(pids, params.deps),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reap orphaned OpenClaw-owned ACPX wrapper trees during runtime startup. */
|
||||
export async function reapStaleOpenClawOwnedAcpxOrphans(params: {
|
||||
wrapperRoot: string;
|
||||
deps?: AcpxProcessCleanupDeps;
|
||||
}): Promise<AcpxStartupReapResult> {
|
||||
if (process.platform === "win32") {
|
||||
return { inspectedPids: [], terminatedPids: [], skippedReason: "unsupported-platform" };
|
||||
}
|
||||
|
||||
let processes: AcpxProcessInfo[];
|
||||
try {
|
||||
processes = await (params.deps?.listProcesses ?? listPlatformProcesses)();
|
||||
} catch {
|
||||
return { inspectedPids: [], terminatedPids: [], skippedReason: "process-list-unavailable" };
|
||||
}
|
||||
|
||||
const orphans = processes.filter(
|
||||
(processInfo) =>
|
||||
processInfo.ppid === 1 &&
|
||||
isOpenClawOwnedAcpxProcessCommand({
|
||||
command: processInfo.command,
|
||||
wrapperRoot: params.wrapperRoot,
|
||||
}),
|
||||
);
|
||||
// Startup reaping starts from currently visible orphan roots and then expands
|
||||
// each tree, so adapter grandchildren do not survive as fresh orphans after
|
||||
// the wrapper root exits.
|
||||
const orphanTrees = orphans.map((orphan) => collectProcessTree(processes, orphan.pid));
|
||||
const inspectedPids = uniquePids(orphanTrees.flat());
|
||||
const pids = uniquePids(orphanTrees.flatMap((tree) => tree.toReversed()));
|
||||
return {
|
||||
inspectedPids,
|
||||
terminatedPids: await terminatePids(pids, params.deps),
|
||||
};
|
||||
}
|
||||
128
extensions/acpx/src/runtime-internals/mcp-command-line.mjs
Normal file
128
extensions/acpx/src/runtime-internals/mcp-command-line.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Command-line parser for ACPX MCP proxy targets. It handles simple quoting and
|
||||
* Windows executable paths before spawning the configured MCP target.
|
||||
*/
|
||||
const WINDOWS_DIRECT_EXECUTABLE_PATH_RE =
|
||||
/^(?<command>(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:exe|com))(?=\s|$)(?:\s+(?<rest>.*))?$/i;
|
||||
|
||||
// Windows wrapper scripts need their host shell or interpreter (`cmd.exe`,
|
||||
// `powershell.exe`, or `node`) instead of direct spawning.
|
||||
const WINDOWS_WRAPPER_PATH_RE =
|
||||
/^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:bat|cmd|cjs|js|mjs|ps1)$/i;
|
||||
|
||||
function splitCommandParts(value, platform = process.platform) {
|
||||
const parts = [];
|
||||
let current = "";
|
||||
let quote = null;
|
||||
let escaping = false;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const ch = value[index];
|
||||
const next = value[index + 1];
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
if (quote === "'") {
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
if (quote === '"') {
|
||||
if (next === '"' || next === "\\") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (!quote) {
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (current.length > 0) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current += "\\";
|
||||
}
|
||||
if (quote) {
|
||||
throw new Error("Invalid agent command: unterminated quote");
|
||||
}
|
||||
if (current.length > 0) {
|
||||
parts.push(current);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function splitWindowsExecutableCommand(value, platform = process.platform) {
|
||||
if (platform !== "win32") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.startsWith('"') || trimmed.startsWith("'")) {
|
||||
return null;
|
||||
}
|
||||
const match = trimmed.match(WINDOWS_DIRECT_EXECUTABLE_PATH_RE);
|
||||
if (!match?.groups?.command) {
|
||||
return null;
|
||||
}
|
||||
const rest = match.groups.rest?.trim() ?? "";
|
||||
return {
|
||||
command: match.groups.command,
|
||||
args: rest ? splitCommandParts(rest, platform) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function assertSupportedWindowsCommand(command, platform = process.platform) {
|
||||
if (platform !== "win32" || !WINDOWS_WRAPPER_PATH_RE.test(command)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported Windows agent command wrapper: ${command}. ` +
|
||||
"Invoke wrapper scripts through their shell or interpreter instead " +
|
||||
"(for example `cmd.exe /c`, `powershell.exe -File`, or `node <script>`).",
|
||||
);
|
||||
}
|
||||
|
||||
/** Split a configured command string into `{ command, args }` for child_process.spawn. */
|
||||
export function splitCommandLine(value, platform = process.platform) {
|
||||
const windowsCommand = splitWindowsExecutableCommand(value, platform);
|
||||
const parts = windowsCommand ?? splitCommandParts(value, platform);
|
||||
if (parts.length === 0) {
|
||||
throw new Error("Invalid agent command: empty command");
|
||||
}
|
||||
const parsed = Array.isArray(parts)
|
||||
? {
|
||||
command: parts[0],
|
||||
args: parts.slice(1),
|
||||
}
|
||||
: parts;
|
||||
assertSupportedWindowsCommand(parsed.command, platform);
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// ACPX tests cover mcp command line plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type SplitCommandLine = (
|
||||
value: string,
|
||||
platform?: string,
|
||||
) => {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
async function loadSplitCommandLine(): Promise<SplitCommandLine> {
|
||||
const moduleUrl = new URL("./mcp-command-line.mjs", import.meta.url);
|
||||
return (await import(moduleUrl.href)).splitCommandLine as SplitCommandLine;
|
||||
}
|
||||
|
||||
describe("mcp-command-line", () => {
|
||||
it("parses quoted Windows executable paths without dropping backslashes", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
const parsed = splitCommandLine(
|
||||
'"C:\\Program Files\\Claude\\claude.exe" --stdio --flag "two words"',
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
command: "C:\\Program Files\\Claude\\claude.exe",
|
||||
args: ["--stdio", "--flag", "two words"],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses unquoted Windows executable paths without mangling backslashes", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
const parsed = splitCommandLine("C:\\Users\\alerl\\.local\\bin\\claude.exe --version", "win32");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
command: "C:\\Users\\alerl\\.local\\bin\\claude.exe",
|
||||
args: ["--version"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unquoted Windows path arguments after the executable", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
const parsed = splitCommandLine(
|
||||
'"C:\\Program Files\\Claude\\claude.exe" --config C:\\Users\\me\\cfg.json',
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
command: "C:\\Program Files\\Claude\\claude.exe",
|
||||
args: ["--config", "C:\\Users\\me\\cfg.json"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects direct Windows wrapper-script commands with a helpful error", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
expect(() =>
|
||||
splitCommandLine('"C:\\Users\\me\\bin\\claude-wrapper.cmd" --stdio', "win32"),
|
||||
).toThrow(/Invoke wrapper scripts through their shell or interpreter instead/);
|
||||
});
|
||||
});
|
||||
159
extensions/acpx/src/runtime-internals/mcp-proxy.mjs
Normal file
159
extensions/acpx/src/runtime-internals/mcp-proxy.mjs
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Stdio MCP proxy used by ACPX wrappers. It injects OpenClaw-provided MCP
|
||||
* servers into session creation/load/fork requests before forwarding to target.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { splitCommandLine } from "./mcp-command-line.mjs";
|
||||
|
||||
function formatErrorMessage(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name || "Error";
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function decodePayload(argv) {
|
||||
const payloadIndex = argv.indexOf("--payload");
|
||||
if (payloadIndex < 0) {
|
||||
throw new Error("Missing --payload");
|
||||
}
|
||||
const encoded = argv[payloadIndex + 1];
|
||||
if (!encoded) {
|
||||
throw new Error("Missing MCP proxy payload value");
|
||||
}
|
||||
const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid MCP proxy payload");
|
||||
}
|
||||
if (typeof parsed.targetCommand !== "string" || parsed.targetCommand.trim() === "") {
|
||||
throw new Error("MCP proxy payload missing targetCommand");
|
||||
}
|
||||
const mcpServers = Array.isArray(parsed.mcpServers) ? parsed.mcpServers : [];
|
||||
return {
|
||||
targetCommand: parsed.targetCommand,
|
||||
mcpServers,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldInject(method) {
|
||||
return method === "session/new" || method === "session/load" || method === "session/fork";
|
||||
}
|
||||
|
||||
function rewriteLine(line, mcpServers) {
|
||||
if (!line.trim()) {
|
||||
return line;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed) ||
|
||||
!shouldInject(parsed.method) ||
|
||||
!parsed.params ||
|
||||
typeof parsed.params !== "object" ||
|
||||
Array.isArray(parsed.params)
|
||||
) {
|
||||
return line;
|
||||
}
|
||||
const next = {
|
||||
...parsed,
|
||||
params: {
|
||||
...parsed.params,
|
||||
mcpServers,
|
||||
},
|
||||
};
|
||||
return JSON.stringify(next);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build spawn options for the proxied MCP target process. */
|
||||
export function createTargetSpawnOptions(platform = process.platform) {
|
||||
const options = {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: process.env,
|
||||
};
|
||||
if (platform === "win32") {
|
||||
options.windowsHide = true;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
const mainPath = process.argv[1];
|
||||
if (!mainPath) {
|
||||
return false;
|
||||
}
|
||||
return import.meta.url === pathToFileURL(path.resolve(mainPath)).href;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { targetCommand, mcpServers } = decodePayload(process.argv.slice(2));
|
||||
const target = splitCommandLine(targetCommand);
|
||||
const child = spawn(target.command, target.args, createTargetSpawnOptions());
|
||||
|
||||
if (!child.stdin || !child.stdout) {
|
||||
throw new Error("Failed to create MCP proxy stdio pipes");
|
||||
}
|
||||
|
||||
const input = createInterface({ input: process.stdin });
|
||||
let exiting = false;
|
||||
|
||||
const exitWithError = (error) => {
|
||||
if (exiting) {
|
||||
return;
|
||||
}
|
||||
exiting = true;
|
||||
input.close();
|
||||
child.kill();
|
||||
process.stderr.write(`${formatErrorMessage(error)}\n`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
child.stdin.on("error", exitWithError);
|
||||
process.stdout.on("error", exitWithError);
|
||||
|
||||
input.on("line", (line) => {
|
||||
if (exiting) {
|
||||
return;
|
||||
}
|
||||
child.stdin.write(`${rewriteLine(line, mcpServers)}\n`, (error) => {
|
||||
if (error) {
|
||||
exitWithError(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
input.on("close", () => {
|
||||
if (exiting || child.stdin.destroyed || child.stdin.writableEnded) {
|
||||
return;
|
||||
}
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
child.stdout.pipe(process.stdout);
|
||||
|
||||
child.on("error", exitWithError);
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (exiting) {
|
||||
return;
|
||||
}
|
||||
exiting = true;
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
main();
|
||||
}
|
||||
241
extensions/acpx/src/runtime-internals/mcp-proxy.test.ts
Normal file
241
extensions/acpx/src/runtime-internals/mcp-proxy.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// ACPX tests cover mcp proxy plugin behavior.
|
||||
import { spawn } from "node:child_process";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { bundledPluginFile } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const proxyPath = path.resolve(bundledPluginFile("acpx", "src/runtime-internals/mcp-proxy.mjs"));
|
||||
|
||||
function encodePayload(payload: Record<string, unknown>): string {
|
||||
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
async function makeTempScript(name: string, content: string): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-mcp-proxy-"));
|
||||
tempDirs.push(dir);
|
||||
const scriptPath = path.join(dir, name);
|
||||
await writeFile(scriptPath, content, "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("mcp-proxy", () => {
|
||||
it("hides the target MCP process window on Windows only", async () => {
|
||||
const moduleUrl = pathToFileURL(proxyPath).href;
|
||||
const { createTargetSpawnOptions } = (await import(moduleUrl)) as {
|
||||
createTargetSpawnOptions: (platform?: NodeJS.Platform) => Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(createTargetSpawnOptions("win32")).toEqual({
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(createTargetSpawnOptions("darwin")).not.toHaveProperty("windowsHide");
|
||||
expect(createTargetSpawnOptions("linux")).not.toHaveProperty("windowsHide");
|
||||
});
|
||||
|
||||
it("injects configured MCP servers into ACP session bootstrap requests", async () => {
|
||||
const echoServerPath = await makeTempScript(
|
||||
"echo-server.cjs",
|
||||
String.raw`#!/usr/bin/env node
|
||||
const { createInterface } = require("node:readline");
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => process.stdout.write(line + "\n"));
|
||||
`,
|
||||
);
|
||||
|
||||
const payload = encodePayload({
|
||||
targetCommand: `${process.execPath} ${echoServerPath}`,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "canva",
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: [{ name: "CANVA_TOKEN", value: "secret" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [proxyPath, "--payload", payload], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: { cwd: process.cwd(), mcpServers: [] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "session/load",
|
||||
params: { cwd: process.cwd(), sessionId: "sid-1", mcpServers: [] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "session/prompt",
|
||||
params: { sessionId: "sid-1", prompt: [{ type: "text", text: "hello" }] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.end();
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
child.once("close", (code) => resolve(code));
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => JSON.parse(line) as { method: string; params: Record<string, unknown> });
|
||||
|
||||
expect(lines[0].params.mcpServers).toEqual([
|
||||
{
|
||||
name: "canva",
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: [{ name: "CANVA_TOKEN", value: "secret" }],
|
||||
},
|
||||
]);
|
||||
expect(lines[1].params.mcpServers).toEqual(lines[0].params.mcpServers);
|
||||
expect(lines[2].method).toBe("session/prompt");
|
||||
expect(lines[2].params.mcpServers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports target stdin pipe failures without an unhandled stream error", async () => {
|
||||
const closedStdinServerPath = await makeTempScript(
|
||||
"closed-stdin-server.cjs",
|
||||
String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
fs.closeSync(0);
|
||||
process.stdout.write("ready\n");
|
||||
setTimeout(() => {}, 30_000);
|
||||
`,
|
||||
);
|
||||
|
||||
const payload = encodePayload({
|
||||
targetCommand: `${process.execPath} ${closedStdinServerPath}`,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [proxyPath, "--payload", payload], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
if (stdout.includes("ready\n")) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
await ready;
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: { cwd: process.cwd(), mcpServers: [] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.end();
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
child.once("close", (code) => resolve(code));
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toMatch(/EPIPE|write/i);
|
||||
expect(stderr).not.toContain("Unhandled 'error' event");
|
||||
});
|
||||
|
||||
it("reports proxy stdout pipe failures without an unhandled stream error", async () => {
|
||||
const outputServerPath = await makeTempScript(
|
||||
"output-server.cjs",
|
||||
String.raw`#!/usr/bin/env node
|
||||
const { createInterface } = require("node:readline");
|
||||
process.stderr.write("ready\n");
|
||||
createInterface({ input: process.stdin }).once("line", () => {
|
||||
process.stdout.write("x".repeat(1024 * 1024));
|
||||
});
|
||||
setTimeout(() => {}, 30_000);
|
||||
`,
|
||||
);
|
||||
|
||||
const payload = encodePayload({
|
||||
targetCommand: `${process.execPath} ${outputServerPath}`,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [proxyPath, "--payload", payload], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
if (stderr.includes("ready\n")) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await ready;
|
||||
child.stdout.destroy();
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: { cwd: process.cwd(), mcpServers: [] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.end();
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
child.once("close", (code) => resolve(code));
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toMatch(/EPIPE|write/i);
|
||||
expect(stderr).not.toContain("Unhandled 'error' event");
|
||||
});
|
||||
});
|
||||
47
extensions/acpx/src/runtime-proxy.ts
Normal file
47
extensions/acpx/src/runtime-proxy.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Lazy ACP runtime proxy for ACPX. It defers resolving the real runtime until
|
||||
* the first ACP call while preserving the SDK runtime shape.
|
||||
*/
|
||||
import type { AcpRuntime } from "../runtime-api.js";
|
||||
import { lazyStartRuntimeTurn } from "./runtime-turn.js";
|
||||
|
||||
/** Create an ACP runtime facade backed by an async runtime resolver. */
|
||||
export function createLazyAcpRuntimeProxy<T extends AcpRuntime>(
|
||||
resolveRuntime: () => Promise<T>,
|
||||
): AcpRuntime {
|
||||
return {
|
||||
async ensureSession(input) {
|
||||
return await (await resolveRuntime()).ensureSession(input);
|
||||
},
|
||||
startTurn(input) {
|
||||
return lazyStartRuntimeTurn(resolveRuntime, input);
|
||||
},
|
||||
async *runTurn(input) {
|
||||
yield* (await resolveRuntime()).runTurn(input);
|
||||
},
|
||||
async getCapabilities(input) {
|
||||
return (await (await resolveRuntime()).getCapabilities?.(input)) ?? { controls: [] };
|
||||
},
|
||||
async getStatus(input) {
|
||||
return (await (await resolveRuntime()).getStatus?.(input)) ?? {};
|
||||
},
|
||||
async setMode(input) {
|
||||
await (await resolveRuntime()).setMode?.(input);
|
||||
},
|
||||
async setConfigOption(input) {
|
||||
await (await resolveRuntime()).setConfigOption?.(input);
|
||||
},
|
||||
async doctor() {
|
||||
return (await (await resolveRuntime()).doctor?.()) ?? { ok: true, message: "ok" };
|
||||
},
|
||||
async prepareFreshSession(input) {
|
||||
await (await resolveRuntime()).prepareFreshSession?.(input);
|
||||
},
|
||||
async cancel(input) {
|
||||
await (await resolveRuntime()).cancel(input);
|
||||
},
|
||||
async close(input) {
|
||||
await (await resolveRuntime()).close(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
191
extensions/acpx/src/runtime-turn.ts
Normal file
191
extensions/acpx/src/runtime-turn.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* ACPX turn adapters. Modern runtimes can expose startTurn directly; legacy
|
||||
* runtimes that only stream runTurn events are adapted to the newer contract.
|
||||
*/
|
||||
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
|
||||
import type {
|
||||
AcpRuntime,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeTurn,
|
||||
AcpRuntimeTurnInput,
|
||||
AcpRuntimeTurnResult,
|
||||
} from "../runtime-api.js";
|
||||
|
||||
class LegacyRunTurnEventQueue {
|
||||
private readonly items: AcpRuntimeEvent[] = [];
|
||||
private readonly waits: Array<{
|
||||
resolve: (value: AcpRuntimeEvent | null) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}> = [];
|
||||
private closed = false;
|
||||
private error: unknown;
|
||||
|
||||
push(item: AcpRuntimeEvent): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
const waiter = this.waits.shift();
|
||||
if (waiter) {
|
||||
waiter.resolve(item);
|
||||
return;
|
||||
}
|
||||
this.items.push(item);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items.length = 0;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
for (const waiter of this.waits.splice(0)) {
|
||||
waiter.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
fail(error: unknown): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.error = error;
|
||||
this.closed = true;
|
||||
for (const waiter of this.waits.splice(0)) {
|
||||
waiter.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async next(): Promise<AcpRuntimeEvent | null> {
|
||||
const item = this.items.shift();
|
||||
if (item) {
|
||||
return item;
|
||||
}
|
||||
if (this.error) {
|
||||
throw toLintErrorObject(this.error, "Non-Error thrown");
|
||||
}
|
||||
if (this.closed) {
|
||||
return null;
|
||||
}
|
||||
return await new Promise<AcpRuntimeEvent | null>((resolve, reject) => {
|
||||
this.waits.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
async *iterate(): AsyncIterable<AcpRuntimeEvent> {
|
||||
for (;;) {
|
||||
const item = await this.next();
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function legacyRunTurnAsStartTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn {
|
||||
const result = createDeferred<AcpRuntimeTurnResult>();
|
||||
result.promise.catch(() => {});
|
||||
const queue = new LegacyRunTurnEventQueue();
|
||||
let resultSettled = false;
|
||||
const settleResult = (next: AcpRuntimeTurnResult) => {
|
||||
if (resultSettled) {
|
||||
return;
|
||||
}
|
||||
resultSettled = true;
|
||||
result.resolve(next);
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const event of runtime.runTurn(input)) {
|
||||
if (event.type === "done") {
|
||||
settleResult({
|
||||
status: "completed",
|
||||
...(event.stopReason ? { stopReason: event.stopReason } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.type === "error") {
|
||||
settleResult({
|
||||
status: "failed",
|
||||
error: {
|
||||
message: event.message,
|
||||
...(event.code ? { code: event.code } : {}),
|
||||
...(event.detailCode ? { detailCode: event.detailCode } : {}),
|
||||
...(event.retryable === undefined ? {} : { retryable: event.retryable }),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
queue.push(event);
|
||||
}
|
||||
settleResult({
|
||||
status: "failed",
|
||||
error: {
|
||||
code: "ACP_TURN_FAILED",
|
||||
message: "ACP turn ended without a terminal done event.",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
result.reject(error);
|
||||
queue.fail(error);
|
||||
return;
|
||||
}
|
||||
queue.close();
|
||||
})();
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
events: queue.iterate(),
|
||||
result: result.promise,
|
||||
async cancel(inputArgs) {
|
||||
await runtime.cancel({ handle: input.handle, reason: inputArgs?.reason });
|
||||
},
|
||||
async closeStream() {
|
||||
queue.clear();
|
||||
queue.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Start an ACP turn, adapting legacy runTurn-only runtimes when needed. */
|
||||
export function startRuntimeTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn {
|
||||
return runtime.startTurn?.(input) ?? legacyRunTurnAsStartTurn(runtime, input);
|
||||
}
|
||||
|
||||
/** Start an ACP turn through a lazy runtime resolver. */
|
||||
export function lazyStartRuntimeTurn(
|
||||
resolveRuntime: () => Promise<AcpRuntime>,
|
||||
input: AcpRuntimeTurnInput,
|
||||
): AcpRuntimeTurn {
|
||||
const turnPromise = resolveRuntime().then((runtime) => startRuntimeTurn(runtime, input));
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
events: {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield* (await turnPromise).events;
|
||||
},
|
||||
},
|
||||
result: turnPromise.then((turn) => turn.result),
|
||||
cancel(inputArgs) {
|
||||
return turnPromise.then((turn) => turn.cancel(inputArgs));
|
||||
},
|
||||
closeStream(inputArgs) {
|
||||
return turnPromise.then((turn) => turn.closeStream(inputArgs));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
2220
extensions/acpx/src/runtime.test.ts
Normal file
2220
extensions/acpx/src/runtime.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1416
extensions/acpx/src/runtime.ts
Normal file
1416
extensions/acpx/src/runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
874
extensions/acpx/src/service.test.ts
Normal file
874
extensions/acpx/src/service.test.ts
Normal file
@@ -0,0 +1,874 @@
|
||||
// ACPX tests cover service plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { runtimeRegistry } = vi.hoisted(() => ({
|
||||
runtimeRegistry: new Map<string, { runtime: unknown; healthy?: () => boolean }>(),
|
||||
}));
|
||||
const { prepareAcpxCodexAuthConfigMock } = vi.hoisted(() => ({
|
||||
prepareAcpxCodexAuthConfigMock: vi.fn(
|
||||
async ({ pluginConfig }: { pluginConfig: unknown }) => pluginConfig,
|
||||
),
|
||||
}));
|
||||
const { cleanupOpenClawOwnedAcpxProcessTreeMock } = vi.hoisted(() => ({
|
||||
cleanupOpenClawOwnedAcpxProcessTreeMock: vi.fn(
|
||||
async (): Promise<{
|
||||
inspectedPids: number[];
|
||||
terminatedPids: number[];
|
||||
skippedReason?: string;
|
||||
}> => ({
|
||||
inspectedPids: [],
|
||||
terminatedPids: [],
|
||||
}),
|
||||
),
|
||||
}));
|
||||
const { reapStaleOpenClawOwnedAcpxOrphansMock } = vi.hoisted(() => ({
|
||||
reapStaleOpenClawOwnedAcpxOrphansMock: vi.fn(
|
||||
async (): Promise<{
|
||||
inspectedPids: number[];
|
||||
terminatedPids: number[];
|
||||
skippedReason?: string;
|
||||
}> => ({
|
||||
inspectedPids: [],
|
||||
terminatedPids: [],
|
||||
}),
|
||||
),
|
||||
}));
|
||||
const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionStoreMock } =
|
||||
vi.hoisted(() => ({
|
||||
acpxRuntimeConstructorMock: vi.fn(function AcpxRuntime(options: unknown) {
|
||||
return {
|
||||
cancel: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
|
||||
ensureSession: vi.fn(async () => ({
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:test",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
})),
|
||||
getCapabilities: vi.fn(async () => ({ controls: [] })),
|
||||
getStatus: vi.fn(async () => ({ summary: "ready" })),
|
||||
isHealthy: vi.fn(() => true),
|
||||
prepareFreshSession: vi.fn(async () => {}),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
runTurn: vi.fn(async function* () {}),
|
||||
setConfigOption: vi.fn(async () => {}),
|
||||
setMode: vi.fn(async () => {}),
|
||||
__options: options,
|
||||
};
|
||||
}),
|
||||
createAgentRegistryMock: vi.fn(() => ({})),
|
||||
createFileSessionStoreMock: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
|
||||
registerAcpRuntimeBackend: (entry: { id: string; runtime: unknown; healthy?: () => boolean }) => {
|
||||
runtimeRegistry.set(entry.id, entry);
|
||||
},
|
||||
unregisterAcpRuntimeBackend: (id: string) => {
|
||||
runtimeRegistry.delete(id);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
ACPX_BACKEND_ID: "acpx",
|
||||
AcpxRuntime: acpxRuntimeConstructorMock,
|
||||
createAgentRegistry: createAgentRegistryMock,
|
||||
createFileSessionStore: createFileSessionStoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("./codex-auth-bridge.js", () => ({
|
||||
prepareAcpxCodexAuthConfig: prepareAcpxCodexAuthConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("./process-reaper.js", () => ({
|
||||
cleanupOpenClawOwnedAcpxProcessTree: cleanupOpenClawOwnedAcpxProcessTreeMock,
|
||||
reapStaleOpenClawOwnedAcpxOrphans: reapStaleOpenClawOwnedAcpxOrphansMock,
|
||||
}));
|
||||
|
||||
import { getAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import type { OpenClawPluginServiceContext } from "../runtime-api.js";
|
||||
import { openAcpxProcessLeaseStateStore, type AcpxProcessLease } from "./process-lease.js";
|
||||
import {
|
||||
createAcpxRuntimeService as createRealAcpxRuntimeService,
|
||||
resolveAcpxTimerTimeoutMs,
|
||||
} from "./service.js";
|
||||
import {
|
||||
ACPX_GATEWAY_INSTANCE_KEY,
|
||||
ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
type AcpxGatewayInstanceRecord,
|
||||
} from "./state.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const previousEnv = {
|
||||
OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE: process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE,
|
||||
OPENCLAW_SKIP_ACPX_RUNTIME: process.env.OPENCLAW_SKIP_ACPX_RUNTIME,
|
||||
OPENCLAW_SKIP_ACPX_RUNTIME_PROBE: process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE,
|
||||
};
|
||||
|
||||
function restoreEnv(name: keyof typeof previousEnv): void {
|
||||
const value = previousEnv[name];
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-service-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
resetPluginStateStoreForTests();
|
||||
runtimeRegistry.clear();
|
||||
prepareAcpxCodexAuthConfigMock.mockClear();
|
||||
cleanupOpenClawOwnedAcpxProcessTreeMock.mockClear();
|
||||
reapStaleOpenClawOwnedAcpxOrphansMock.mockClear();
|
||||
acpxRuntimeConstructorMock.mockClear();
|
||||
createAgentRegistryMock.mockClear();
|
||||
createFileSessionStoreMock.mockClear();
|
||||
restoreEnv("OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE");
|
||||
restoreEnv("OPENCLAW_SKIP_ACPX_RUNTIME");
|
||||
restoreEnv("OPENCLAW_SKIP_ACPX_RUNTIME_PROBE");
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createServiceContext(workspaceDir: string): OpenClawPluginServiceContext {
|
||||
return {
|
||||
workspaceDir,
|
||||
stateDir: path.join(workspaceDir, ".openclaw-plugin-state"),
|
||||
config: {},
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenKeyedStore(ctx: OpenClawPluginServiceContext) {
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: ctx.stateDir };
|
||||
return <T>(options: OpenKeyedStoreOptions) =>
|
||||
createPluginStateKeyedStoreForTests<T>("acpx", {
|
||||
...options,
|
||||
env: options.env ?? env,
|
||||
});
|
||||
}
|
||||
|
||||
function createAcpxRuntimeService(
|
||||
ctx: OpenClawPluginServiceContext,
|
||||
params: Parameters<typeof createRealAcpxRuntimeService>[0] = {},
|
||||
) {
|
||||
return createRealAcpxRuntimeService({
|
||||
...params,
|
||||
openKeyedStore: params.openKeyedStore ?? createOpenKeyedStore(ctx),
|
||||
});
|
||||
}
|
||||
|
||||
function openGatewayInstanceStore(ctx: OpenClawPluginServiceContext) {
|
||||
return createOpenKeyedStore(ctx)<AcpxGatewayInstanceRecord>({
|
||||
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
maxEntries: ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
});
|
||||
}
|
||||
|
||||
function openProcessLeaseStore(ctx: OpenClawPluginServiceContext) {
|
||||
return openAcpxProcessLeaseStateStore(createOpenKeyedStore(ctx));
|
||||
}
|
||||
|
||||
function createMockRuntime(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
isHealthy: vi.fn(() => true),
|
||||
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStartupTraceRecorder() {
|
||||
const measured: string[] = [];
|
||||
const details: Array<{
|
||||
name: string;
|
||||
metrics: ReadonlyArray<readonly [string, number | string]>;
|
||||
}> = [];
|
||||
return {
|
||||
measured,
|
||||
details,
|
||||
startupTrace: {
|
||||
measure: async <T>(name: string, run: () => T | Promise<T>): Promise<T> => {
|
||||
measured.push(name);
|
||||
return await run();
|
||||
},
|
||||
detail: (name: string, metrics: ReadonlyArray<readonly [string, number | string]>) => {
|
||||
details.push({ name, metrics });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readFirstRuntimeFactoryInput(runtimeFactory: { mock: { calls: Array<Array<unknown>> } }) {
|
||||
const [call] = runtimeFactory.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("Expected runtimeFactory to be called");
|
||||
}
|
||||
const [input] = call;
|
||||
if (typeof input !== "object" || input === null) {
|
||||
throw new Error("Expected runtimeFactory to be called with an options object");
|
||||
}
|
||||
return input as {
|
||||
pluginConfig: {
|
||||
timeoutSeconds?: number;
|
||||
probeAgent?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAcpxRuntimeService", () => {
|
||||
it("caps configured timeout seconds to timer-safe milliseconds", () => {
|
||||
expect(resolveAcpxTimerTimeoutMs(0.001)).toBe(1);
|
||||
expect(resolveAcpxTimerTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("registers and unregisters the embedded backend", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = createMockRuntime();
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips the startup probe and does not advertise backend health when explicitly disabled", async () => {
|
||||
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
|
||||
const workspaceDir = await makeTempDir();
|
||||
const stateDir = path.join(workspaceDir, "custom-state");
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(async () => {
|
||||
await fs.access(stateDir);
|
||||
});
|
||||
const runtime = createMockRuntime({
|
||||
doctor: async () => ({ ok: true, message: "ok" }),
|
||||
isHealthy: () => false,
|
||||
probeAvailability,
|
||||
});
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
pluginConfig: { stateDir },
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
await fs.access(stateDir);
|
||||
expect(probeAvailability).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")?.healthy).toBeUndefined();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("waits for the embedded runtime startup probe before resolving by default", async () => {
|
||||
delete process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE;
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
let releaseProbe!: () => void;
|
||||
const probeStarted = vi.fn();
|
||||
const probeAvailability = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
probeStarted();
|
||||
releaseProbe = resolve;
|
||||
}),
|
||||
);
|
||||
const runtime = createMockRuntime({
|
||||
probeAvailability,
|
||||
isHealthy: () => true,
|
||||
});
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
const startPromise = service.start(ctx) as Promise<void>;
|
||||
await vi.waitFor(() => {
|
||||
expect(probeStarted).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
void startPromise.then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(resolved).toBe(false);
|
||||
releaseProbe();
|
||||
await startPromise;
|
||||
|
||||
expect(resolved).toBe(true);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith("embedded acpx runtime backend ready");
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("emits ACPX-owned startup trace subspans", async () => {
|
||||
delete process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE;
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const trace = createStartupTraceRecorder();
|
||||
ctx.startupTrace = trace.startupTrace;
|
||||
const runtime = createMockRuntime();
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(trace.measured).toEqual([
|
||||
"config.resolve",
|
||||
"config.prepare-codex-auth",
|
||||
"filesystem.prepare",
|
||||
"gateway-instance-id",
|
||||
"process-leases.reap",
|
||||
"runtime.create",
|
||||
"backend.register",
|
||||
"probe.availability",
|
||||
]);
|
||||
expect(trace.details).toEqual([
|
||||
{
|
||||
name: "probe-policy",
|
||||
metrics: [
|
||||
["startupProbeEnabledCount", 1],
|
||||
["probeAgent", "default"],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "probe.result",
|
||||
metrics: [["healthyCount", 1]],
|
||||
},
|
||||
]);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("reaps stale ACPX process leases from the generated wrapper root at startup", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = createMockRuntime();
|
||||
const processCleanupDeps = { sleep: vi.fn(async () => {}) };
|
||||
const wrapperRoot = path.join(ctx.stateDir, "acpx");
|
||||
await openGatewayInstanceStore(ctx).register(ACPX_GATEWAY_INSTANCE_KEY, {
|
||||
instanceId: "gw-test",
|
||||
createdAt: 1,
|
||||
});
|
||||
const lease: AcpxProcessLease = {
|
||||
leaseId: "lease-1",
|
||||
gatewayInstanceId: "gw-test",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
wrapperRoot,
|
||||
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
|
||||
rootPid: 101,
|
||||
commandHash: "hash",
|
||||
startedAt: 1,
|
||||
state: "open",
|
||||
};
|
||||
await openProcessLeaseStore(ctx).register(lease.leaseId, lease);
|
||||
cleanupOpenClawOwnedAcpxProcessTreeMock.mockResolvedValueOnce({
|
||||
inspectedPids: [101, 102],
|
||||
terminatedPids: [101, 102],
|
||||
});
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory: () => runtime as never,
|
||||
processCleanupDeps,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(cleanupOpenClawOwnedAcpxProcessTreeMock).toHaveBeenCalledWith({
|
||||
rootPid: 101,
|
||||
expectedLeaseId: "lease-1",
|
||||
expectedGatewayInstanceId: "gw-test",
|
||||
wrapperRoot,
|
||||
deps: processCleanupDeps,
|
||||
});
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith("reaped 2 stale OpenClaw-owned ACPX processes");
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("runs wrapper-root orphan cleanup before dropping pending ACPX leases", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = createMockRuntime();
|
||||
const processCleanupDeps = { sleep: vi.fn(async () => {}) };
|
||||
const wrapperRoot = path.join(ctx.stateDir, "acpx");
|
||||
await fs.mkdir(wrapperRoot, { recursive: true });
|
||||
await openGatewayInstanceStore(ctx).register(ACPX_GATEWAY_INSTANCE_KEY, {
|
||||
instanceId: "gw-test",
|
||||
createdAt: 1,
|
||||
});
|
||||
const lease: AcpxProcessLease = {
|
||||
leaseId: "lease-pending",
|
||||
gatewayInstanceId: "gw-test",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
wrapperRoot,
|
||||
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
|
||||
rootPid: 0,
|
||||
commandHash: "hash",
|
||||
startedAt: 1,
|
||||
state: "open",
|
||||
};
|
||||
await openProcessLeaseStore(ctx).register(lease.leaseId, lease);
|
||||
reapStaleOpenClawOwnedAcpxOrphansMock.mockResolvedValueOnce({
|
||||
inspectedPids: [201, 202],
|
||||
terminatedPids: [201, 202],
|
||||
});
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory: () => runtime as never,
|
||||
processCleanupDeps,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(cleanupOpenClawOwnedAcpxProcessTreeMock).not.toHaveBeenCalled();
|
||||
expect(reapStaleOpenClawOwnedAcpxOrphansMock).toHaveBeenCalledWith({
|
||||
wrapperRoot,
|
||||
deps: processCleanupDeps,
|
||||
});
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith("reaped 2 stale OpenClaw-owned ACPX processes");
|
||||
await expect(openProcessLeaseStore(ctx).lookup("lease-pending")).resolves.toBeUndefined();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("keeps startup quiet when no process leases are open", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = createMockRuntime();
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(cleanupOpenClawOwnedAcpxProcessTreeMock).not.toHaveBeenCalled();
|
||||
expect(ctx.logger.warn).not.toHaveBeenCalled();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("registers the backend lazily without importing ACPX runtime when startup probe is disabled", async () => {
|
||||
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const service = createAcpxRuntimeService(ctx);
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
const backend = getAcpRuntimeBackend("acpx");
|
||||
if (!backend) {
|
||||
throw new Error("expected ACPX runtime backend");
|
||||
}
|
||||
const backendRuntime = backend.runtime as {
|
||||
ensureSession(input: { agent: string; mode: string; sessionKey: string }): Promise<unknown>;
|
||||
};
|
||||
expect(typeof backendRuntime.ensureSession).toBe("function");
|
||||
expect(backend.healthy).toBeUndefined();
|
||||
expect(acpxRuntimeConstructorMock).not.toHaveBeenCalled();
|
||||
|
||||
await backendRuntime.ensureSession({
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
});
|
||||
|
||||
expect(acpxRuntimeConstructorMock).toHaveBeenCalledOnce();
|
||||
expect(backend.healthy).toBeUndefined();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("adapts lazy runTurn-only default runtimes for startTurn callers", async () => {
|
||||
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runTurn = vi.fn(async function* () {
|
||||
yield {
|
||||
type: "text_delta" as const,
|
||||
stream: "output" as const,
|
||||
text: "legacy progress",
|
||||
};
|
||||
yield {
|
||||
type: "done" as const,
|
||||
stopReason: "end_turn",
|
||||
};
|
||||
});
|
||||
acpxRuntimeConstructorMock.mockImplementationOnce(function AcpxRuntime(options: unknown) {
|
||||
return {
|
||||
...createMockRuntime({
|
||||
runTurn,
|
||||
}),
|
||||
getCapabilities: vi.fn(async () => ({ controls: [] })),
|
||||
getStatus: vi.fn(async () => ({ summary: "ready" })),
|
||||
prepareFreshSession: vi.fn(async () => {}),
|
||||
setConfigOption: vi.fn(async () => {}),
|
||||
setMode: vi.fn(async () => {}),
|
||||
__options: options,
|
||||
};
|
||||
});
|
||||
const service = createAcpxRuntimeService(ctx);
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
const backend = getAcpRuntimeBackend("acpx");
|
||||
if (!backend) {
|
||||
throw new Error("expected ACPX runtime backend");
|
||||
}
|
||||
const backendRuntime = backend.runtime as {
|
||||
startTurn(input: {
|
||||
handle: { sessionKey: string; backend: string; runtimeSessionName: string };
|
||||
text: string;
|
||||
mode: string;
|
||||
requestId: string;
|
||||
}): {
|
||||
events: AsyncIterable<unknown>;
|
||||
result: Promise<unknown>;
|
||||
};
|
||||
};
|
||||
const turn = backendRuntime.startTurn({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:test",
|
||||
},
|
||||
text: "hello",
|
||||
mode: "prompt",
|
||||
requestId: "turn-1",
|
||||
});
|
||||
await expect(turn.result).resolves.toEqual({
|
||||
status: "completed",
|
||||
stopReason: "end_turn",
|
||||
});
|
||||
const events = [];
|
||||
for await (const event of turn.events) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "text_delta",
|
||||
stream: "output",
|
||||
text: "legacy progress",
|
||||
},
|
||||
]);
|
||||
expect(runTurn).toHaveBeenCalledOnce();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("passes the plugin timeout to the default acpx runtime constructor", async () => {
|
||||
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
pluginConfig: { timeoutSeconds: 0.001 },
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
const backend = getAcpRuntimeBackend("acpx");
|
||||
if (!backend) {
|
||||
throw new Error("expected ACPX runtime backend");
|
||||
}
|
||||
const backendRuntime = backend.runtime as {
|
||||
ensureSession(input: { agent: string; mode: string; sessionKey: string }): Promise<unknown>;
|
||||
};
|
||||
|
||||
await backendRuntime.ensureSession({
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
});
|
||||
|
||||
const [options] = acpxRuntimeConstructorMock.mock.calls[0] ?? [];
|
||||
expect(options).toHaveProperty("timeoutMs", 1);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("caps oversized plugin timeouts before constructing the default acpx runtime", async () => {
|
||||
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
pluginConfig: { timeoutSeconds: Number.MAX_SAFE_INTEGER },
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
const backend = getAcpRuntimeBackend("acpx");
|
||||
if (!backend) {
|
||||
throw new Error("expected ACPX runtime backend");
|
||||
}
|
||||
const backendRuntime = backend.runtime as {
|
||||
ensureSession(input: { agent: string; mode: string; sessionKey: string }): Promise<unknown>;
|
||||
};
|
||||
|
||||
await backendRuntime.ensureSession({
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
});
|
||||
|
||||
const [options] = acpxRuntimeConstructorMock.mock.calls[0] ?? [];
|
||||
expect(options).toHaveProperty("timeoutMs", MAX_TIMER_TIMEOUT_MS);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("runs the embedded runtime probe at startup when explicitly enabled and reports health", async () => {
|
||||
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(async () => {});
|
||||
const runtime = createMockRuntime({
|
||||
probeAvailability,
|
||||
isHealthy: () => true,
|
||||
});
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(probeAvailability).toHaveBeenCalledOnce();
|
||||
expect(getAcpRuntimeBackend("acpx")?.healthy?.()).toBe(true);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("bounds the opt-in embedded runtime startup probe wait with the configured timeout", async () => {
|
||||
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(() => new Promise<void>(() => {}));
|
||||
const runtime = createMockRuntime({
|
||||
probeAvailability,
|
||||
isHealthy: () => false,
|
||||
});
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
pluginConfig: { timeoutSeconds: 0.001 },
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(probeAvailability).toHaveBeenCalledOnce();
|
||||
expect(getAcpRuntimeBackend("acpx")?.healthy?.()).toBe(false);
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
"embedded acpx runtime setup failed: embedded acpx runtime backend startup probe timed out after 0.001s",
|
||||
);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("passes the default runtime timeout to the embedded runtime factory", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = createMockRuntime();
|
||||
const runtimeFactory = vi.fn(() => runtime as never);
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(readFirstRuntimeFactoryInput(runtimeFactory).pluginConfig.timeoutSeconds).toBe(120);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("forwards a configured probeAgent to the runtime factory so the probe does not hardcode the default", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = {
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
isHealthy: vi.fn(() => true),
|
||||
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
|
||||
};
|
||||
const runtimeFactory = vi.fn(() => runtime as never);
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
pluginConfig: { probeAgent: "opencode" },
|
||||
runtimeFactory,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(readFirstRuntimeFactoryInput(runtimeFactory).pluginConfig.probeAgent).toBe("opencode");
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("uses the first allowed ACP agent as the default probe agent", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
ctx.config = {
|
||||
acp: {
|
||||
allowedAgents: [" OpenCode ", "codex"],
|
||||
},
|
||||
};
|
||||
const runtime = createMockRuntime();
|
||||
const runtimeFactory = vi.fn(() => runtime as never);
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(readFirstRuntimeFactoryInput(runtimeFactory).pluginConfig.probeAgent).toBe("opencode");
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("keeps explicit probeAgent ahead of acp.allowedAgents", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
ctx.config = {
|
||||
acp: {
|
||||
allowedAgents: ["opencode"],
|
||||
},
|
||||
};
|
||||
const runtime = createMockRuntime();
|
||||
const runtimeFactory = vi.fn(() => runtime as never);
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
pluginConfig: { probeAgent: "codex" },
|
||||
runtimeFactory,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(readFirstRuntimeFactoryInput(runtimeFactory).pluginConfig.probeAgent).toBe("codex");
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("warns when legacy compatibility config is explicitly ignored", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = createMockRuntime();
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
pluginConfig: {
|
||||
queueOwnerTtlSeconds: 30,
|
||||
strictWindowsCmdWrapper: false,
|
||||
},
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
"embedded acpx runtime ignores legacy compatibility config: queueOwnerTtlSeconds, strictWindowsCmdWrapper=false",
|
||||
);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("lets the skip env override the opt-in embedded runtime startup probe without advertising health", async () => {
|
||||
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(async () => {});
|
||||
const runtime = createMockRuntime({
|
||||
doctor: async () => ({ ok: false, message: "nope" }),
|
||||
isHealthy: () => false,
|
||||
probeAvailability,
|
||||
});
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(probeAvailability).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
|
||||
expect(getAcpRuntimeBackend("acpx")?.healthy).toBeUndefined();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("formats non-string doctor details without losing object payloads", async () => {
|
||||
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = createMockRuntime({
|
||||
doctor: async () => ({
|
||||
ok: false,
|
||||
message: "probe failed",
|
||||
details: [{ code: "ACP_CLOSED", agent: "codex" }, new Error("stdin closed")],
|
||||
}),
|
||||
isHealthy: () => false,
|
||||
});
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
'embedded acpx runtime backend probe failed: probe failed ({"code":"ACP_CLOSED","agent":"codex"}; stdin closed)',
|
||||
);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("can skip the embedded runtime backend via env", async () => {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtimeFactory = vi.fn(() => {
|
||||
throw new Error("runtime factory should not run when ACPX is skipped");
|
||||
});
|
||||
const service = createAcpxRuntimeService(ctx, {
|
||||
runtimeFactory: runtimeFactory as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(runtimeFactory).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
"skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)",
|
||||
);
|
||||
});
|
||||
});
|
||||
466
extensions/acpx/src/service.ts
Normal file
466
extensions/acpx/src/service.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* ACPX plugin service lifecycle. It resolves config, prepares isolated adapter
|
||||
* wrappers, registers the ACP backend, and manages startup/cleanup probes.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { inspect } from "node:util";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginStateKeyedStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import type {
|
||||
AcpRuntime,
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginLogger,
|
||||
} from "../runtime-api.js";
|
||||
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
|
||||
import { DEFAULT_ACPX_TIMEOUT_SECONDS } from "./config-schema.js";
|
||||
import {
|
||||
resolveAcpxPluginConfig,
|
||||
toAcpMcpServers,
|
||||
type ResolvedAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
import {
|
||||
createAcpxProcessLeaseStore,
|
||||
openAcpxProcessLeaseStateStore,
|
||||
type AcpxProcessLeaseStore,
|
||||
} from "./process-lease.js";
|
||||
import {
|
||||
cleanupOpenClawOwnedAcpxProcessTree,
|
||||
reapStaleOpenClawOwnedAcpxOrphans,
|
||||
type AcpxProcessCleanupDeps,
|
||||
} from "./process-reaper.js";
|
||||
import { createLazyAcpRuntimeProxy } from "./runtime-proxy.js";
|
||||
import {
|
||||
ACPX_GATEWAY_INSTANCE_KEY,
|
||||
ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
normalizeAcpxGatewayInstanceRecord,
|
||||
type AcpxGatewayInstanceRecord,
|
||||
} from "./state.js";
|
||||
|
||||
type AcpxRuntimeLike = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
isHealthy(): boolean;
|
||||
doctor?(): Promise<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
details?: string[];
|
||||
}>;
|
||||
};
|
||||
const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE";
|
||||
const SKIP_RUNTIME_PROBE_ENV = "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE";
|
||||
const ACPX_BACKEND_ID = "acpx";
|
||||
|
||||
type AcpxRuntimeFactoryParams = {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
gatewayInstanceId: string;
|
||||
processLeaseStore: AcpxProcessLeaseStore;
|
||||
wrapperRoot: string;
|
||||
logger?: PluginLogger;
|
||||
};
|
||||
|
||||
type CreateAcpxRuntimeServiceParams = {
|
||||
pluginConfig?: unknown;
|
||||
openKeyedStore?: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T>;
|
||||
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike | Promise<AcpxRuntimeLike>;
|
||||
processCleanupDeps?: AcpxProcessCleanupDeps;
|
||||
};
|
||||
|
||||
const loadRuntimeModule = createLazyRuntimeModule(() => import("./runtime.js"));
|
||||
|
||||
/** Convert ACPX timeout seconds into timer-safe milliseconds. */
|
||||
export function resolveAcpxTimerTimeoutMs(timeoutSeconds: number | undefined): number | undefined {
|
||||
if (timeoutSeconds === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return finiteSecondsToTimerSafeMilliseconds(timeoutSeconds) ?? 1;
|
||||
}
|
||||
|
||||
function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
|
||||
let runtime: AcpxRuntimeLike | null = null;
|
||||
let runtimePromise: Promise<AcpxRuntimeLike> | null = null;
|
||||
|
||||
async function resolveRuntime(): Promise<AcpxRuntimeLike> {
|
||||
if (runtime) {
|
||||
return runtime;
|
||||
}
|
||||
runtimePromise ??= loadRuntimeModule().then((module) => {
|
||||
runtime = new module.AcpxRuntime({
|
||||
cwd: params.pluginConfig.cwd,
|
||||
openclawGatewayInstanceId: params.gatewayInstanceId,
|
||||
openclawProcessLeaseStore: params.processLeaseStore,
|
||||
openclawWrapperRoot: params.wrapperRoot,
|
||||
sessionStore: module.createFileSessionStore({
|
||||
stateDir: params.pluginConfig.stateDir,
|
||||
}),
|
||||
agentRegistry: module.createAgentRegistry({
|
||||
overrides: params.pluginConfig.agents,
|
||||
}),
|
||||
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),
|
||||
}) as AcpxRuntimeLike;
|
||||
return runtime;
|
||||
});
|
||||
return await runtimePromise;
|
||||
}
|
||||
|
||||
return {
|
||||
...createLazyAcpRuntimeProxy(resolveRuntime),
|
||||
async probeAvailability() {
|
||||
await (await resolveRuntime()).probeAvailability();
|
||||
},
|
||||
isHealthy() {
|
||||
return runtime?.isHealthy() ?? false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function warnOnIgnoredLegacyCompatibilityConfig(params: {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
logger?: PluginLogger;
|
||||
}): void {
|
||||
const ignoredFields: string[] = [];
|
||||
if (params.pluginConfig.legacyCompatibilityConfig.queueOwnerTtlSeconds != null) {
|
||||
ignoredFields.push("queueOwnerTtlSeconds");
|
||||
}
|
||||
if (params.pluginConfig.legacyCompatibilityConfig.strictWindowsCmdWrapper === false) {
|
||||
ignoredFields.push("strictWindowsCmdWrapper=false");
|
||||
}
|
||||
if (ignoredFields.length === 0) {
|
||||
return;
|
||||
}
|
||||
params.logger?.warn(
|
||||
`embedded acpx runtime ignores legacy compatibility config: ${ignoredFields.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatDoctorDetail(detail: unknown): string | null {
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
if (typeof detail === "string") {
|
||||
return detail.trim() || null;
|
||||
}
|
||||
if (detail instanceof Error) {
|
||||
return formatErrorMessage(detail);
|
||||
}
|
||||
if (typeof detail === "object") {
|
||||
try {
|
||||
return JSON.stringify(detail) ?? inspect(detail, { breakLength: Infinity, depth: 3 });
|
||||
} catch {
|
||||
return inspect(detail, { breakLength: Infinity, depth: 3 });
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof detail === "number" ||
|
||||
typeof detail === "boolean" ||
|
||||
typeof detail === "bigint" ||
|
||||
typeof detail === "symbol"
|
||||
) {
|
||||
return detail.toString();
|
||||
}
|
||||
return inspect(detail, { breakLength: Infinity, depth: 3 });
|
||||
}
|
||||
|
||||
function formatDoctorFailureMessage(report: { message: string; details?: unknown[] }): string {
|
||||
const detailText = report.details?.map(formatDoctorDetail).filter(Boolean).join("; ").trim();
|
||||
return detailText ? `${report.message} (${detailText})` : report.message;
|
||||
}
|
||||
|
||||
function normalizeProbeAgent(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function resolveAllowedAgentsProbeAgent(ctx: OpenClawPluginServiceContext): string | undefined {
|
||||
for (const agent of ctx.config.acp?.allowedAgents ?? []) {
|
||||
const normalized = normalizeProbeAgent(agent);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function measureAcpxStartup<T>(
|
||||
ctx: OpenClawPluginServiceContext,
|
||||
name: string,
|
||||
run: () => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
return ctx.startupTrace ? await ctx.startupTrace.measure(name, run) : await run();
|
||||
}
|
||||
|
||||
function detailAcpxStartup(
|
||||
ctx: OpenClawPluginServiceContext,
|
||||
name: string,
|
||||
metrics: ReadonlyArray<readonly [string, number | string]>,
|
||||
): void {
|
||||
ctx.startupTrace?.detail?.(name, metrics);
|
||||
}
|
||||
|
||||
function shouldRunStartupProbe(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env[ENABLE_STARTUP_PROBE_ENV] !== "0";
|
||||
}
|
||||
|
||||
function shouldProbeRuntimeAtStartup(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return shouldRunStartupProbe(env) && env[SKIP_RUNTIME_PROBE_ENV] !== "1";
|
||||
}
|
||||
|
||||
async function withStartupProbeTimeout<T>(params: {
|
||||
promise: Promise<T>;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutMs = resolveAcpxTimerTimeoutMs(params.timeoutSeconds) ?? 1;
|
||||
try {
|
||||
return await Promise.race([
|
||||
params.promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`embedded acpx runtime backend startup probe timed out after ${params.timeoutSeconds}s`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
(timeout as { unref?: () => void }).unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openGatewayInstanceStateStore(
|
||||
openKeyedStore: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T>,
|
||||
): PluginStateKeyedStore<AcpxGatewayInstanceRecord> {
|
||||
return openKeyedStore<AcpxGatewayInstanceRecord>({
|
||||
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
maxEntries: ACPX_GATEWAY_INSTANCE_MAX_ENTRIES,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveGatewayInstanceId(
|
||||
openKeyedStore: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T>,
|
||||
): Promise<string> {
|
||||
const store = openGatewayInstanceStateStore(openKeyedStore);
|
||||
const existing = normalizeAcpxGatewayInstanceRecord(
|
||||
await store.lookup(ACPX_GATEWAY_INSTANCE_KEY),
|
||||
);
|
||||
if (existing) {
|
||||
return existing.instanceId;
|
||||
}
|
||||
const next = randomUUID();
|
||||
await store.register(ACPX_GATEWAY_INSTANCE_KEY, {
|
||||
instanceId: next,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
async function reapOpenAcpxProcessLeases(params: {
|
||||
gatewayInstanceId: string;
|
||||
leaseStore: AcpxProcessLeaseStore;
|
||||
deps?: AcpxProcessCleanupDeps;
|
||||
}): Promise<{ inspectedPids: number[]; terminatedPids: number[] }> {
|
||||
const leases = await params.leaseStore.listOpen(params.gatewayInstanceId);
|
||||
const inspectedPids: number[] = [];
|
||||
const terminatedPids: number[] = [];
|
||||
const pendingLeaseRootResults = new Map<
|
||||
string,
|
||||
{ inspectedPids: number[]; terminatedPids: number[] }
|
||||
>();
|
||||
for (const lease of leases) {
|
||||
if (lease.rootPid <= 0) {
|
||||
await params.leaseStore.markState(lease.leaseId, "closing");
|
||||
let result = pendingLeaseRootResults.get(lease.wrapperRoot);
|
||||
if (!result) {
|
||||
result = await reapStaleOpenClawOwnedAcpxOrphans({
|
||||
wrapperRoot: lease.wrapperRoot,
|
||||
deps: params.deps,
|
||||
});
|
||||
pendingLeaseRootResults.set(lease.wrapperRoot, result);
|
||||
inspectedPids.push(...result.inspectedPids);
|
||||
terminatedPids.push(...result.terminatedPids);
|
||||
}
|
||||
await params.leaseStore.markState(
|
||||
lease.leaseId,
|
||||
result.terminatedPids.length > 0 ? "closed" : "lost",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await params.leaseStore.markState(lease.leaseId, "closing");
|
||||
const result = await cleanupOpenClawOwnedAcpxProcessTree({
|
||||
rootPid: lease.rootPid,
|
||||
expectedLeaseId: lease.leaseId,
|
||||
expectedGatewayInstanceId: lease.gatewayInstanceId,
|
||||
wrapperRoot: lease.wrapperRoot,
|
||||
deps: params.deps,
|
||||
});
|
||||
inspectedPids.push(...result.inspectedPids);
|
||||
terminatedPids.push(...result.terminatedPids);
|
||||
await params.leaseStore.markState(
|
||||
lease.leaseId,
|
||||
result.terminatedPids.length > 0 ? "closed" : "lost",
|
||||
);
|
||||
}
|
||||
return { inspectedPids, terminatedPids };
|
||||
}
|
||||
|
||||
/** Create the ACPX plugin service that owns runtime registration and cleanup. */
|
||||
export function createAcpxRuntimeService(
|
||||
params: CreateAcpxRuntimeServiceParams = {},
|
||||
): OpenClawPluginService {
|
||||
let runtime: AcpxRuntimeLike | null = null;
|
||||
let lifecycleRevision = 0;
|
||||
|
||||
return {
|
||||
id: "acpx-runtime",
|
||||
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
|
||||
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
|
||||
return;
|
||||
}
|
||||
const openKeyedStore = params.openKeyedStore;
|
||||
if (!openKeyedStore) {
|
||||
throw new Error("ACPX runtime service requires plugin keyed state");
|
||||
}
|
||||
|
||||
const basePluginConfig = await measureAcpxStartup(ctx, "config.resolve", () =>
|
||||
resolveAcpxPluginConfig({
|
||||
rawConfig: params.pluginConfig,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
}),
|
||||
);
|
||||
const effectiveBasePluginConfig: ResolvedAcpxPluginConfig = {
|
||||
...basePluginConfig,
|
||||
probeAgent: basePluginConfig.probeAgent ?? resolveAllowedAgentsProbeAgent(ctx),
|
||||
};
|
||||
const pluginConfig = await measureAcpxStartup(ctx, "config.prepare-codex-auth", () =>
|
||||
prepareAcpxCodexAuthConfig({
|
||||
pluginConfig: effectiveBasePluginConfig,
|
||||
stateDir: ctx.stateDir,
|
||||
logger: ctx.logger,
|
||||
}),
|
||||
);
|
||||
const wrapperRoot = path.join(ctx.stateDir, "acpx");
|
||||
await measureAcpxStartup(ctx, "filesystem.prepare", async () => {
|
||||
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
|
||||
await fs.mkdir(wrapperRoot, { recursive: true });
|
||||
});
|
||||
const gatewayInstanceId = await measureAcpxStartup(ctx, "gateway-instance-id", () =>
|
||||
resolveGatewayInstanceId(openKeyedStore),
|
||||
);
|
||||
const processLeaseStore = createAcpxProcessLeaseStore({
|
||||
store: openAcpxProcessLeaseStateStore(openKeyedStore),
|
||||
});
|
||||
const startupReap = await measureAcpxStartup(ctx, "process-leases.reap", () =>
|
||||
reapOpenAcpxProcessLeases({
|
||||
gatewayInstanceId,
|
||||
leaseStore: processLeaseStore,
|
||||
deps: params.processCleanupDeps,
|
||||
}),
|
||||
);
|
||||
if (startupReap.terminatedPids.length > 0) {
|
||||
ctx.logger.info(
|
||||
`reaped ${startupReap.terminatedPids.length} stale OpenClaw-owned ACPX process${startupReap.terminatedPids.length === 1 ? "" : "es"}`,
|
||||
);
|
||||
}
|
||||
warnOnIgnoredLegacyCompatibilityConfig({
|
||||
pluginConfig,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
const startedRuntime = await measureAcpxStartup(ctx, "runtime.create", () =>
|
||||
params.runtimeFactory
|
||||
? params.runtimeFactory({
|
||||
pluginConfig,
|
||||
gatewayInstanceId,
|
||||
processLeaseStore,
|
||||
wrapperRoot,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
: createLazyDefaultRuntime({
|
||||
pluginConfig,
|
||||
gatewayInstanceId,
|
||||
processLeaseStore,
|
||||
wrapperRoot,
|
||||
logger: ctx.logger,
|
||||
}),
|
||||
);
|
||||
runtime = startedRuntime;
|
||||
|
||||
const shouldProbeRuntime = shouldProbeRuntimeAtStartup();
|
||||
detailAcpxStartup(ctx, "probe-policy", [
|
||||
["startupProbeEnabledCount", shouldProbeRuntime ? 1 : 0],
|
||||
["probeAgent", pluginConfig.probeAgent ?? "default"],
|
||||
]);
|
||||
await measureAcpxStartup(ctx, "backend.register", () => {
|
||||
registerAcpRuntimeBackend({
|
||||
id: ACPX_BACKEND_ID,
|
||||
runtime: startedRuntime,
|
||||
...(shouldProbeRuntime ? { healthy: () => runtime?.isHealthy() ?? false } : {}),
|
||||
});
|
||||
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
|
||||
});
|
||||
|
||||
if (!shouldProbeRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
lifecycleRevision += 1;
|
||||
const currentRevision = lifecycleRevision;
|
||||
try {
|
||||
await measureAcpxStartup(ctx, "probe.availability", () =>
|
||||
withStartupProbeTimeout({
|
||||
promise: startedRuntime.probeAvailability(),
|
||||
timeoutSeconds: pluginConfig.timeoutSeconds ?? DEFAULT_ACPX_TIMEOUT_SECONDS,
|
||||
}),
|
||||
);
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
if (startedRuntime.isHealthy()) {
|
||||
detailAcpxStartup(ctx, "probe.result", [["healthyCount", 1]]);
|
||||
ctx.logger.info("embedded acpx runtime backend ready");
|
||||
return;
|
||||
}
|
||||
const doctorReport = await measureAcpxStartup(ctx, "probe.doctor", () =>
|
||||
startedRuntime.doctor?.(),
|
||||
);
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
detailAcpxStartup(ctx, "probe.result", [["healthyCount", 0]]);
|
||||
ctx.logger.warn(
|
||||
`embedded acpx runtime backend probe failed: ${doctorReport ? formatDoctorFailureMessage(doctorReport) : "backend remained unhealthy after probe"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
detailAcpxStartup(ctx, "probe.result", [["healthyCount", 0]]);
|
||||
ctx.logger.warn(`embedded acpx runtime setup failed: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
},
|
||||
async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
lifecycleRevision += 1;
|
||||
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
|
||||
runtime = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
34
extensions/acpx/src/state.ts
Normal file
34
extensions/acpx/src/state.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// ACPX plugin state keys shared by runtime and doctor migration.
|
||||
export const ACPX_PROCESS_LEASE_NAMESPACE = "process-leases";
|
||||
export const ACPX_PROCESS_LEASE_MAX_ENTRIES = 4096;
|
||||
export const ACPX_LEGACY_PROCESS_LEASE_FILE = "process-leases.json";
|
||||
|
||||
export const ACPX_GATEWAY_INSTANCE_NAMESPACE = "gateway-instance";
|
||||
export const ACPX_GATEWAY_INSTANCE_KEY = "current";
|
||||
export const ACPX_GATEWAY_INSTANCE_MAX_ENTRIES = 1;
|
||||
export const ACPX_LEGACY_GATEWAY_INSTANCE_FILE = "gateway-instance-id";
|
||||
|
||||
export type AcpxGatewayInstanceRecord = {
|
||||
instanceId: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export function normalizeAcpxGatewayInstanceRecord(
|
||||
value: unknown,
|
||||
): AcpxGatewayInstanceRecord | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.instanceId !== "string" || !record.instanceId.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const createdAt =
|
||||
typeof record.createdAt === "number" && Number.isFinite(record.createdAt)
|
||||
? Math.trunc(record.createdAt)
|
||||
: 0;
|
||||
return {
|
||||
instanceId: record.instanceId.trim(),
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
16
extensions/acpx/tsconfig.json
Normal file
16
extensions/acpx/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
140
extensions/active-memory/config.test.ts
Normal file
140
extensions/active-memory/config.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Active Memory tests cover config plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
type JsonSchemaObject,
|
||||
validateJsonSchemaValue,
|
||||
} from "openclaw/plugin-sdk/json-schema-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
) as { configSchema: JsonSchemaObject };
|
||||
|
||||
describe("active-memory manifest config schema", () => {
|
||||
it("accepts modelFallback for CLI and config.patch flows", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.model-fallback",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
modelFallback: "google/gemini-3-flash",
|
||||
modelFallbackPolicy: "resolved-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts custom toolsAllow entries", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.tools-allow",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects wildcard and group toolsAllow entries", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.tools-allow.reserved",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
toolsAllow: ["*", "group:plugins"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts timeoutMs values at the runtime ceiling", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.timeout-ceiling",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts setupGraceTimeoutMs values at the runtime ceiling", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.setup-grace-timeout-ceiling",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
setupGraceTimeoutMs: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts explicit in allowedChatTypes", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.allowed-chat-types.explicit",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct", "explicit"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects timeoutMs values above the runtime ceiling", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.timeout-above-ceiling",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
timeoutMs: 120_001,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects setupGraceTimeoutMs values above the runtime ceiling", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.setup-grace-timeout-above-ceiling",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
setupGraceTimeoutMs: 30_001,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown allowedChatTypes values", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.allowed-chat-types.invalid",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct", "portal"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
100
extensions/active-memory/doctor-contract-api.test.ts
Normal file
100
extensions/active-memory/doctor-contract-api.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Active Memory tests cover doctor contract api plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginDoctorStateMigrationContext,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { stateMigrations } from "./doctor-contract-api.js";
|
||||
|
||||
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
|
||||
return {
|
||||
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
|
||||
return createPluginStateKeyedStoreForTests<T>("active-memory", {
|
||||
...options,
|
||||
env: options.env ?? env,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("active-memory doctor state migration", () => {
|
||||
let stateDir = "";
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetPluginStateStoreForTests();
|
||||
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-doctor-"));
|
||||
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("imports legacy session opt-outs into plugin state", async () => {
|
||||
const sourcePath = path.join(stateDir, "plugins", "active-memory", "session-toggles.json");
|
||||
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
sessions: {
|
||||
"telegram:dm:123": { disabled: true, updatedAt: 1700 },
|
||||
"telegram:dm:456": { disabled: false, updatedAt: 1701 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const migration = stateMigrations[0];
|
||||
await expect(
|
||||
migration.detectLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
preview: [expect.stringContaining("1 entry")],
|
||||
});
|
||||
|
||||
const result = await migration.migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
});
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.changes).toEqual([
|
||||
expect.stringContaining("Migrated 1 Active Memory session toggle entry"),
|
||||
expect.stringContaining("Archived Active Memory session toggles legacy source"),
|
||||
]);
|
||||
await expect(fs.access(sourcePath)).rejects.toThrow();
|
||||
await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined();
|
||||
|
||||
const entries = await createDoctorContext(env)
|
||||
.openPluginStateKeyedStore({
|
||||
namespace: "session-toggles",
|
||||
maxEntries: 10_000,
|
||||
})
|
||||
.entries();
|
||||
expect(entries).toMatchObject([
|
||||
{
|
||||
key: expect.any(String),
|
||||
value: {
|
||||
sessionKey: "telegram:dm:123",
|
||||
disabled: true,
|
||||
updatedAt: 1700,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
124
extensions/active-memory/doctor-contract-api.ts
Normal file
124
extensions/active-memory/doctor-contract-api.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Doctor migration contract for Active Memory state. It moves legacy per-session
|
||||
* toggle JSON into the plugin state keyed store used by current runtimes.
|
||||
*/
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
archiveLegacyStateSource,
|
||||
type PluginDoctorStateMigration,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
|
||||
type ActiveMemoryToggleEntry = {
|
||||
sessionKey: string;
|
||||
disabled: boolean;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const TOGGLE_STATE_FILE = "session-toggles.json";
|
||||
const SESSION_TOGGLES_NAMESPACE = "session-toggles";
|
||||
const MAX_TOGGLE_ENTRIES = 10_000;
|
||||
|
||||
function resolveToggleStatePath(stateDir: string): string {
|
||||
return path.join(stateDir, "plugins", "active-memory", TOGGLE_STATE_FILE);
|
||||
}
|
||||
|
||||
function activeMemoryToggleKey(sessionKey: string): string {
|
||||
return crypto.createHash("sha256").update(sessionKey, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
async function readLegacyToggleEntries(filePath: string): Promise<ActiveMemoryToggleEntry[]> {
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return [];
|
||||
}
|
||||
const sessions = (parsed as { sessions?: unknown }).sessions;
|
||||
if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) {
|
||||
return [];
|
||||
}
|
||||
const entries: ActiveMemoryToggleEntry[] = [];
|
||||
for (const [sessionKey, value] of Object.entries(sessions)) {
|
||||
if (!sessionKey.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
if ((value as { disabled?: unknown }).disabled !== true) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt =
|
||||
typeof (value as { updatedAt?: unknown }).updatedAt === "number"
|
||||
? (value as { updatedAt: number }).updatedAt
|
||||
: Date.now();
|
||||
entries.push({ sessionKey, disabled: true, updatedAt });
|
||||
}
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** State migrations exposed to OpenClaw doctor for Active Memory. */
|
||||
export const stateMigrations: PluginDoctorStateMigration[] = [
|
||||
{
|
||||
id: "active-memory-session-toggles-json-to-plugin-state",
|
||||
label: "Active Memory session toggles",
|
||||
async detectLegacyState(params) {
|
||||
const filePath = resolveToggleStatePath(params.stateDir);
|
||||
const entries = await readLegacyToggleEntries(filePath);
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
preview: [
|
||||
`- Active Memory session toggles: ${entries.length} ${entries.length === 1 ? "entry" : "entries"} -> plugin state (${SESSION_TOGGLES_NAMESPACE})`,
|
||||
],
|
||||
};
|
||||
},
|
||||
async migrateLegacyState(params) {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const filePath = resolveToggleStatePath(params.stateDir);
|
||||
const entries = await readLegacyToggleEntries(filePath);
|
||||
if (entries.length === 0) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
const store = params.context.openPluginStateKeyedStore<ActiveMemoryToggleEntry>({
|
||||
namespace: SESSION_TOGGLES_NAMESPACE,
|
||||
maxEntries: MAX_TOGGLE_ENTRIES,
|
||||
});
|
||||
const existingKeys = new Set((await store.entries()).map((entry) => entry.key));
|
||||
const missingEntries = entries.filter(
|
||||
(entry) => !existingKeys.has(activeMemoryToggleKey(entry.sessionKey)),
|
||||
);
|
||||
if (missingEntries.length > MAX_TOGGLE_ENTRIES - existingKeys.size) {
|
||||
warnings.push(
|
||||
`Skipped Active Memory session toggle migration because plugin state has room for ${MAX_TOGGLE_ENTRIES - existingKeys.size} of ${missingEntries.length} missing entries; left legacy source in place`,
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
let imported = 0;
|
||||
for (const entry of entries) {
|
||||
const key = activeMemoryToggleKey(entry.sessionKey);
|
||||
if (existingKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
await store.register(key, entry);
|
||||
existingKeys.add(key);
|
||||
imported++;
|
||||
}
|
||||
if (imported > 0) {
|
||||
changes.push(
|
||||
`Migrated ${imported} Active Memory session toggle ${imported === 1 ? "entry" : "entries"} -> plugin state`,
|
||||
);
|
||||
}
|
||||
await archiveLegacyStateSource({
|
||||
filePath,
|
||||
label: "Active Memory session toggles",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
return { changes, warnings };
|
||||
},
|
||||
},
|
||||
];
|
||||
5828
extensions/active-memory/index.test.ts
Normal file
5828
extensions/active-memory/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
3792
extensions/active-memory/index.ts
Normal file
3792
extensions/active-memory/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
186
extensions/active-memory/openclaw.plugin.json
Normal file
186
extensions/active-memory/openclaw.plugin.json
Normal file
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"id": "active-memory",
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"name": "Active Memory",
|
||||
"description": "Runs a bounded blocking memory sub-agent before eligible conversational replies and injects relevant memory into prompt context.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"model": { "type": "string" },
|
||||
"modelFallback": { "type": "string" },
|
||||
"modelFallbackPolicy": {
|
||||
"type": "string",
|
||||
"enum": ["default-remote", "resolved-only"]
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["direct", "group", "channel", "explicit"]
|
||||
}
|
||||
},
|
||||
"allowedChatIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"deniedChatIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"thinking": {
|
||||
"type": "string",
|
||||
"enum": ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"]
|
||||
},
|
||||
"timeoutMs": { "type": "integer", "minimum": 250, "maximum": 120000 },
|
||||
"setupGraceTimeoutMs": { "type": "integer", "minimum": 0, "maximum": 30000 },
|
||||
"queryMode": {
|
||||
"type": "string",
|
||||
"enum": ["message", "recent", "full"]
|
||||
},
|
||||
"promptStyle": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"balanced",
|
||||
"strict",
|
||||
"contextual",
|
||||
"recall-heavy",
|
||||
"precision-heavy",
|
||||
"preference-only"
|
||||
]
|
||||
},
|
||||
"toolsAllow": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!\\*$)(?![Gg][Rr][Oo][Uu][Pp]:).+"
|
||||
},
|
||||
"maxItems": 32
|
||||
},
|
||||
"promptOverride": { "type": "string" },
|
||||
"promptAppend": { "type": "string" },
|
||||
"maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"recentUserTurns": { "type": "integer", "minimum": 0, "maximum": 4 },
|
||||
"recentAssistantTurns": { "type": "integer", "minimum": 0, "maximum": 3 },
|
||||
"recentUserChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"logging": { "type": "boolean" },
|
||||
"persistTranscripts": { "type": "boolean" },
|
||||
"transcriptDir": { "type": "string" },
|
||||
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
|
||||
"circuitBreakerMaxTimeouts": { "type": "integer", "minimum": 1, "maximum": 20 },
|
||||
"circuitBreakerCooldownMs": { "type": "integer", "minimum": 5000, "maximum": 600000 },
|
||||
"qmd": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"searchMode": {
|
||||
"type": "string",
|
||||
"enum": ["inherit", "search", "vsearch", "query"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"enabled": {
|
||||
"label": "Active Memory Recall",
|
||||
"help": "Globally enable or pause Active Memory recall while keeping the plugin command available."
|
||||
},
|
||||
"agents": {
|
||||
"label": "Target Agents",
|
||||
"help": "Explicit agent ids that may use active memory."
|
||||
},
|
||||
"model": {
|
||||
"label": "Memory Model",
|
||||
"help": "Provider/model used for the blocking memory sub-agent."
|
||||
},
|
||||
"modelFallback": {
|
||||
"label": "Fallback Memory Model",
|
||||
"help": "Optional provider/model to use if no explicit plugin model, session model, or agent primary model resolves."
|
||||
},
|
||||
"modelFallbackPolicy": {
|
||||
"label": "Model Fallback Policy",
|
||||
"help": "Deprecated compatibility field. modelFallback is only the chain-resolution last resort when no explicit plugin model, session model, or agent primary model resolves; it is not runtime failover."
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"label": "Allowed Chat Types",
|
||||
"help": "Choose which session types may run Active Memory. Defaults to direct-message style sessions only, but explicit portal/webchat sessions can also be enabled."
|
||||
},
|
||||
"allowedChatIds": {
|
||||
"label": "Allowed Chat IDs",
|
||||
"help": "Optional explicit allowlist of chat/user IDs (e.g. Feishu chat_id oc_xxx, open_id ou_xxx, Telegram chat id, Slack channel id). When non-empty, Active Memory only runs for sessions whose conversation id is in the list, across **every** chat type at once (direct, group, channel). Setting this narrows every allowed chat type simultaneously — if you want 'all directs + only specific groups', use allowedChatTypes: ['group'] + allowedChatIds: [<group ids>] and rely on direct chats being matched via the direct session id (e.g. the user's open_id) instead. Leave empty to fall back to allowedChatTypes alone."
|
||||
},
|
||||
"deniedChatIds": {
|
||||
"label": "Denied Chat IDs",
|
||||
"help": "Optional explicit denylist of chat/user IDs. Sessions whose resolved conversation id matches the list are skipped even when the chat type is allowed. Applied after allowedChatIds."
|
||||
},
|
||||
"timeoutMs": {
|
||||
"label": "Timeout (ms)",
|
||||
"help": "Recall work budget on the main lane. Before recall, the hook allows up to 1500 ms for session/config preflight. After recall starts, it reserves another fixed 1500 ms only for abort settlement and transcript recovery."
|
||||
},
|
||||
"setupGraceTimeoutMs": {
|
||||
"label": "Setup Grace Timeout (ms)",
|
||||
"help": "Advanced: extra recall-work budget for cold embedded-run setup. Defaults to 0. The separate 1500 ms preflight cap and 1500 ms post-recall completion allowance still apply."
|
||||
},
|
||||
"queryMode": {
|
||||
"label": "Query Mode",
|
||||
"help": "Choose whether the blocking memory sub-agent sees only the latest user message, a small recent tail, or the full conversation."
|
||||
},
|
||||
"promptStyle": {
|
||||
"label": "Prompt Style",
|
||||
"help": "Choose how eager or strict the blocking memory sub-agent should be when deciding whether to return memory."
|
||||
},
|
||||
"toolsAllow": {
|
||||
"label": "Allowed Memory Tools",
|
||||
"help": "Advanced: tool names the blocking memory sub-agent may use. Defaults to memory_search and memory_get, or memory_recall when plugins.slots.memory selects memory-lancedb; configure this for other non-core memory providers. Wildcards, group entries, and core agent tools are ignored."
|
||||
},
|
||||
"thinking": {
|
||||
"label": "Thinking Override",
|
||||
"help": "Advanced: optional thinking level for the blocking memory sub-agent. Defaults to off for speed."
|
||||
},
|
||||
"promptOverride": {
|
||||
"label": "Prompt Override",
|
||||
"help": "Advanced: replace the default Active Memory sub-agent instructions. Conversation context is still appended."
|
||||
},
|
||||
"promptAppend": {
|
||||
"label": "Prompt Append",
|
||||
"help": "Advanced: append extra operator instructions after the default Active Memory sub-agent instructions."
|
||||
},
|
||||
"maxSummaryChars": {
|
||||
"label": "Max Summary Characters",
|
||||
"help": "Maximum total characters allowed in the active-memory summary."
|
||||
},
|
||||
"logging": {
|
||||
"label": "Enable Logging",
|
||||
"help": "Emit active memory timing and result logs."
|
||||
},
|
||||
"circuitBreakerMaxTimeouts": {
|
||||
"label": "Circuit Breaker Max Timeouts",
|
||||
"help": "Skip recall after this many consecutive timeouts for the same agent/model. Resets on a successful recall or after the cooldown expires. Default: 3."
|
||||
},
|
||||
"circuitBreakerCooldownMs": {
|
||||
"label": "Circuit Breaker Cooldown (ms)",
|
||||
"help": "How long to skip recall after the circuit breaker trips, in milliseconds. Default: 60000 (1 minute)."
|
||||
},
|
||||
"persistTranscripts": {
|
||||
"label": "Persist Transcripts",
|
||||
"help": "Keep blocking memory sub-agent session transcripts on disk in a separate plugin-owned directory."
|
||||
},
|
||||
"transcriptDir": {
|
||||
"label": "Transcript Directory",
|
||||
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
|
||||
},
|
||||
"qmd.searchMode": {
|
||||
"label": "QMD Search Mode",
|
||||
"help": "Override the QMD search mode used by the blocking memory sub-agent. Defaults to fast lexical search; use inherit to match the main memory backend setting."
|
||||
}
|
||||
}
|
||||
}
|
||||
33
extensions/admin-http-rpc/index.test.ts
Normal file
33
extensions/admin-http-rpc/index.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Admin Http Rpc tests cover index plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
describe("admin-http-rpc plugin entry", () => {
|
||||
it("stays startup-off until the plugin entry is explicitly enabled", () => {
|
||||
expect(manifest.activation).toEqual({
|
||||
onStartup: false,
|
||||
onConfigPaths: ["plugins.entries.admin-http-rpc"],
|
||||
});
|
||||
expect(manifest.contracts).toEqual({
|
||||
gatewayMethodDispatch: ["authenticated-request"],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers one trusted gateway HTTP route", () => {
|
||||
const routes: Array<Record<string, unknown>> = [];
|
||||
plugin.register({
|
||||
registerHttpRoute(route) {
|
||||
routes.push(route as unknown as Record<string, unknown>);
|
||||
},
|
||||
} as Parameters<typeof plugin.register>[0]);
|
||||
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0]).toMatchObject({
|
||||
path: "/api/v1/admin/rpc",
|
||||
auth: "gateway",
|
||||
match: "exact",
|
||||
gatewayRuntimeScopeSurface: "trusted-operator",
|
||||
});
|
||||
});
|
||||
});
|
||||
21
extensions/admin-http-rpc/index.ts
Normal file
21
extensions/admin-http-rpc/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Admin HTTP RPC plugin entry. It exposes a trusted gateway-authenticated HTTP
|
||||
* endpoint for the explicit admin method allowlist.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { handleAdminHttpRpcRequest } from "./src/handler.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "admin-http-rpc",
|
||||
name: "Admin HTTP RPC",
|
||||
description: "Expose selected Gateway admin RPC methods over HTTP",
|
||||
register(api) {
|
||||
api.registerHttpRoute({
|
||||
path: "/api/v1/admin/rpc",
|
||||
auth: "gateway",
|
||||
match: "exact",
|
||||
gatewayRuntimeScopeSurface: "trusted-operator",
|
||||
handler: handleAdminHttpRpcRequest,
|
||||
});
|
||||
},
|
||||
});
|
||||
15
extensions/admin-http-rpc/openclaw.plugin.json
Normal file
15
extensions/admin-http-rpc/openclaw.plugin.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "admin-http-rpc",
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
"onConfigPaths": ["plugins.entries.admin-http-rpc"]
|
||||
},
|
||||
"contracts": {
|
||||
"gatewayMethodDispatch": ["authenticated-request"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
15
extensions/admin-http-rpc/package.json
Normal file
15
extensions/admin-http-rpc/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/admin-http-rpc",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw admin HTTP RPC endpoint",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
188
extensions/admin-http-rpc/src/handler.test.ts
Normal file
188
extensions/admin-http-rpc/src/handler.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// Admin Http Rpc tests cover handler plugin behavior.
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleAdminHttpRpcRequest } from "./handler.js";
|
||||
import { listAdminHttpRpcAllowedMethods } from "./methods.js";
|
||||
|
||||
const { dispatchGatewayMethod } = vi.hoisted(() => ({
|
||||
dispatchGatewayMethod: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/gateway-method-runtime", () => ({
|
||||
dispatchGatewayMethod,
|
||||
}));
|
||||
|
||||
type CapturedResponse = {
|
||||
statusCode: number;
|
||||
headers: Record<string, string | number | readonly string[]>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function createRequest(body: unknown, method = "POST") {
|
||||
const req = Readable.from([typeof body === "string" ? body : JSON.stringify(body)]);
|
||||
Object.assign(req, {
|
||||
method,
|
||||
url: "/api/v1/admin/rpc",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
return req as import("node:http").IncomingMessage;
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
const captured: CapturedResponse = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: "",
|
||||
};
|
||||
const res = {
|
||||
get statusCode() {
|
||||
return captured.statusCode;
|
||||
},
|
||||
set statusCode(value: number) {
|
||||
captured.statusCode = value;
|
||||
},
|
||||
setHeader(name: string, value: string | number | readonly string[]) {
|
||||
captured.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
end(chunk?: string | Buffer) {
|
||||
captured.body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : (chunk ?? "");
|
||||
},
|
||||
} as import("node:http").ServerResponse;
|
||||
return { res, captured };
|
||||
}
|
||||
|
||||
async function invoke(body: unknown, method = "POST") {
|
||||
const { res, captured } = createResponse();
|
||||
const handled = await handleAdminHttpRpcRequest(createRequest(body, method), res);
|
||||
return {
|
||||
handled,
|
||||
captured,
|
||||
json: captured.body ? (JSON.parse(captured.body) as unknown) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("admin-http-rpc plugin handler", () => {
|
||||
beforeEach(() => {
|
||||
dispatchGatewayMethod.mockReset();
|
||||
});
|
||||
|
||||
it("returns the allowlist without dispatching through the Gateway", async () => {
|
||||
const result = await invoke({ id: "1", method: "commands.list" });
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.captured.statusCode).toBe(200);
|
||||
expect(result.json).toEqual({
|
||||
id: "1",
|
||||
ok: true,
|
||||
payload: {
|
||||
methods: listAdminHttpRpcAllowedMethods(),
|
||||
},
|
||||
});
|
||||
expect(dispatchGatewayMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches allowed methods through the authenticated plugin request scope", async () => {
|
||||
dispatchGatewayMethod.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: { status: "ok" },
|
||||
meta: { requestId: "abc" },
|
||||
});
|
||||
|
||||
const result = await invoke({
|
||||
id: "cfg",
|
||||
method: "config.get",
|
||||
params: { path: "gateway" },
|
||||
});
|
||||
|
||||
expect(dispatchGatewayMethod).toHaveBeenCalledWith("config.get", { path: "gateway" });
|
||||
expect(result.captured.statusCode).toBe(200);
|
||||
expect(result.json).toEqual({
|
||||
id: "cfg",
|
||||
ok: true,
|
||||
payload: { status: "ok" },
|
||||
meta: { requestId: "abc" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["web.login.start", { force: true, timeoutMs: 1000 }],
|
||||
["web.login.wait", { timeoutMs: 1000 }],
|
||||
] as const)(
|
||||
"allows web QR login method %s through the authenticated plugin request scope",
|
||||
async (method, params) => {
|
||||
dispatchGatewayMethod.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: { status: "ok" },
|
||||
});
|
||||
|
||||
const result = await invoke({
|
||||
id: "web-login",
|
||||
method,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(dispatchGatewayMethod).toHaveBeenCalledWith(method, params);
|
||||
expect(result.captured.statusCode).toBe(200);
|
||||
expect(result.json).toEqual({
|
||||
id: "web-login",
|
||||
ok: true,
|
||||
payload: { status: "ok" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects methods outside the admin HTTP RPC allowlist", async () => {
|
||||
const result = await invoke({ id: "bad", method: "sessions.send" });
|
||||
|
||||
expect(dispatchGatewayMethod).not.toHaveBeenCalled();
|
||||
expect(result.captured.statusCode).toBe(400);
|
||||
expect(result.json).toEqual({
|
||||
id: "bad",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "admin HTTP RPC method is not supported: sessions.send",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Gateway errors to HTTP status codes", async () => {
|
||||
dispatchGatewayMethod.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: "NOT_PAIRED", message: "pair first" },
|
||||
});
|
||||
|
||||
const result = await invoke({ id: "node", method: "node.list" });
|
||||
|
||||
expect(result.captured.statusCode).toBe(409);
|
||||
expect(result.json).toEqual({
|
||||
id: "node",
|
||||
ok: false,
|
||||
error: { code: "NOT_PAIRED", message: "pair first" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid request bodies before dispatch", async () => {
|
||||
const result = await invoke({ id: "missing" });
|
||||
|
||||
expect(result.captured.statusCode).toBe(400);
|
||||
expect(result.json).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "invalid_request",
|
||||
message: "method must be a non-empty string",
|
||||
},
|
||||
});
|
||||
expect(dispatchGatewayMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only accepts POST", async () => {
|
||||
const result = await invoke({ method: "status" }, "GET");
|
||||
|
||||
expect(result.captured.statusCode).toBe(405);
|
||||
expect(result.captured.headers.allow).toBe("POST");
|
||||
expect(dispatchGatewayMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
238
extensions/admin-http-rpc/src/handler.ts
Normal file
238
extensions/admin-http-rpc/src/handler.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* HTTP handler for the Admin RPC endpoint. It validates JSON requests, enforces
|
||||
* the method allowlist, dispatches gateway methods, and maps errors to HTTP.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { dispatchGatewayMethod } from "openclaw/plugin-sdk/gateway-method-runtime";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { isAdminHttpRpcAllowedMethod, listAdminHttpRpcAllowedMethods } from "./methods.js";
|
||||
|
||||
const DEFAULT_RPC_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
const ErrorCodes = {
|
||||
AGENT_TIMEOUT: "AGENT_TIMEOUT",
|
||||
APPROVAL_NOT_FOUND: "APPROVAL_NOT_FOUND",
|
||||
INVALID_REQUEST: "INVALID_REQUEST",
|
||||
NOT_LINKED: "NOT_LINKED",
|
||||
NOT_PAIRED: "NOT_PAIRED",
|
||||
UNAVAILABLE: "UNAVAILABLE",
|
||||
} as const;
|
||||
|
||||
type RpcBody = {
|
||||
id?: unknown;
|
||||
method?: unknown;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
type RpcError = {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
retryable?: boolean;
|
||||
retryAfterMs?: number;
|
||||
};
|
||||
|
||||
type RpcResponse =
|
||||
| { id: string; ok: true; payload: unknown; meta?: Record<string, unknown> }
|
||||
| { id: string; ok: false; error: RpcError; meta?: Record<string, unknown> };
|
||||
|
||||
type ParsedRequest = {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
function createError(code: string, message: string): RpcError {
|
||||
return { code, message };
|
||||
}
|
||||
|
||||
function rpcHttpStatus(response: RpcResponse): number {
|
||||
if (response.ok) {
|
||||
return 200;
|
||||
}
|
||||
switch (response.error.code) {
|
||||
case ErrorCodes.INVALID_REQUEST:
|
||||
return 400;
|
||||
case ErrorCodes.APPROVAL_NOT_FOUND:
|
||||
return 404;
|
||||
case ErrorCodes.UNAVAILABLE:
|
||||
return 503;
|
||||
case ErrorCodes.AGENT_TIMEOUT:
|
||||
return 504;
|
||||
case ErrorCodes.NOT_LINKED:
|
||||
case ErrorCodes.NOT_PAIRED:
|
||||
return 409;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, error: { type: string; message: string }) {
|
||||
sendJson(res, status, { ok: false, error });
|
||||
}
|
||||
|
||||
async function readJsonBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes: number,
|
||||
): Promise<{ ok: true; value: unknown } | { ok: false; status: number; message: string }> {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalBytes += buffer.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
return { ok: false, status: 413, message: "Payload too large" };
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, status: 400, message: "failed to read request body" };
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw.trim()) {
|
||||
return { ok: false, status: 400, message: "request body must be JSON" };
|
||||
}
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(raw) };
|
||||
} catch {
|
||||
return { ok: false, status: 400, message: "request body must be valid JSON" };
|
||||
}
|
||||
}
|
||||
|
||||
function readRpcRequestBody(body: unknown):
|
||||
| { ok: true; request: ParsedRequest }
|
||||
| {
|
||||
ok: false;
|
||||
message: string;
|
||||
} {
|
||||
if (!isRecord(body)) {
|
||||
return { ok: false, message: "request body must be an object" };
|
||||
}
|
||||
const rpcBody = body as RpcBody;
|
||||
if (typeof rpcBody.method !== "string" || rpcBody.method.trim().length === 0) {
|
||||
return { ok: false, message: "method must be a non-empty string" };
|
||||
}
|
||||
const id =
|
||||
typeof rpcBody.id === "string" && rpcBody.id.trim().length > 0
|
||||
? rpcBody.id.trim()
|
||||
: randomUUID();
|
||||
return {
|
||||
ok: true,
|
||||
request: {
|
||||
id,
|
||||
method: rpcBody.method.trim(),
|
||||
...(Object.hasOwn(rpcBody, "params") ? { params: rpcBody.params } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function methodNotAllowed(id: string, method: string): RpcResponse {
|
||||
return {
|
||||
id,
|
||||
ok: false,
|
||||
error: createError(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`admin HTTP RPC method is not supported: ${method}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function commandsList(id: string): RpcResponse {
|
||||
return {
|
||||
id,
|
||||
ok: true,
|
||||
payload: {
|
||||
methods: listAdminHttpRpcAllowedMethods(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchAdminRpc(request: ParsedRequest): Promise<RpcResponse> {
|
||||
try {
|
||||
const response = await dispatchGatewayMethod(request.method, request.params);
|
||||
if (response.ok) {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
payload: response.payload,
|
||||
...(response.meta ? { meta: response.meta } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error:
|
||||
response.error ??
|
||||
createError(ErrorCodes.UNAVAILABLE, "gateway method failed before returning a response"),
|
||||
...(response.meta ? { meta: response.meta } : {}),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: createError(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
"gateway method failed before returning a response",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle one gateway-authenticated Admin HTTP RPC request. */
|
||||
export async function handleAdminHttpRpcRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
if ((req.method ?? "GET").toUpperCase() !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
sendError(res, 405, {
|
||||
type: "method_not_allowed",
|
||||
message: "Method Not Allowed",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readJsonBody(req, DEFAULT_RPC_BODY_BYTES);
|
||||
if (!body.ok) {
|
||||
sendError(res, body.status, {
|
||||
type: "invalid_request",
|
||||
message: body.message,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = readRpcRequestBody(body.value);
|
||||
if (!parsed.ok) {
|
||||
sendError(res, 400, {
|
||||
type: "invalid_request",
|
||||
message: parsed.message,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isAdminHttpRpcAllowedMethod(parsed.request.method)) {
|
||||
const response = methodNotAllowed(parsed.request.id, parsed.request.method);
|
||||
sendJson(res, rpcHttpStatus(response), response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parsed.request.method === "commands.list") {
|
||||
const response = commandsList(parsed.request.id);
|
||||
sendJson(res, 200, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = await dispatchAdminRpc(parsed.request);
|
||||
sendJson(res, rpcHttpStatus(response), response);
|
||||
return true;
|
||||
}
|
||||
69
extensions/admin-http-rpc/src/methods.ts
Normal file
69
extensions/admin-http-rpc/src/methods.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Method allowlist for Admin HTTP RPC. Only methods listed here can cross the
|
||||
* trusted operator HTTP surface.
|
||||
*/
|
||||
const ADMIN_HTTP_RPC_ALLOWED_METHOD_GROUPS = {
|
||||
gateway: [
|
||||
"health",
|
||||
"status",
|
||||
"logs.tail",
|
||||
"usage.status",
|
||||
"usage.cost",
|
||||
"gateway.restart.request",
|
||||
],
|
||||
discovery: ["commands.list"],
|
||||
config: [
|
||||
"config.get",
|
||||
"config.schema",
|
||||
"config.schema.lookup",
|
||||
"config.set",
|
||||
"config.patch",
|
||||
"config.apply",
|
||||
],
|
||||
channels: ["channels.status", "channels.start", "channels.stop", "channels.logout"],
|
||||
web: ["web.login.start", "web.login.wait"],
|
||||
models: ["models.list", "models.authStatus"],
|
||||
agents: ["agents.list", "agents.create", "agents.update", "agents.delete"],
|
||||
approvals: [
|
||||
"exec.approvals.get",
|
||||
"exec.approvals.set",
|
||||
"exec.approvals.node.get",
|
||||
"exec.approvals.node.set",
|
||||
],
|
||||
cron: [
|
||||
"cron.status",
|
||||
"cron.list",
|
||||
"cron.get",
|
||||
"cron.runs",
|
||||
"cron.add",
|
||||
"cron.update",
|
||||
"cron.remove",
|
||||
"cron.run",
|
||||
],
|
||||
devices: ["device.pair.list", "device.pair.approve", "device.pair.reject", "device.pair.remove"],
|
||||
nodes: [
|
||||
"node.list",
|
||||
"node.describe",
|
||||
"node.pair.list",
|
||||
"node.pair.approve",
|
||||
"node.pair.reject",
|
||||
"node.pair.remove",
|
||||
"node.rename",
|
||||
],
|
||||
tasks: ["tasks.list", "tasks.get", "tasks.cancel"],
|
||||
diagnostics: ["doctor.memory.status", "update.status"],
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
const ADMIN_HTTP_RPC_ALLOWED_METHODS: ReadonlySet<string> = new Set(
|
||||
Object.values(ADMIN_HTTP_RPC_ALLOWED_METHOD_GROUPS).flat(),
|
||||
);
|
||||
|
||||
/** Return whether an admin RPC method is exposed over HTTP. */
|
||||
export function isAdminHttpRpcAllowedMethod(method: string): boolean {
|
||||
return ADMIN_HTTP_RPC_ALLOWED_METHODS.has(method);
|
||||
}
|
||||
|
||||
/** List all admin RPC methods exposed over HTTP. */
|
||||
export function listAdminHttpRpcAllowedMethods(): string[] {
|
||||
return Array.from(ADMIN_HTTP_RPC_ALLOWED_METHODS);
|
||||
}
|
||||
16
extensions/admin-http-rpc/tsconfig.json
Normal file
16
extensions/admin-http-rpc/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
15
extensions/alibaba/index.ts
Normal file
15
extensions/alibaba/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Alibaba Model Studio plugin entry. Registers the DashScope-backed video
|
||||
* generation provider.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildAlibabaVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "alibaba",
|
||||
name: "Alibaba Model Studio Plugin",
|
||||
description: "Bundled Alibaba Model Studio video provider plugin",
|
||||
register(api) {
|
||||
api.registerVideoGenerationProvider(buildAlibabaVideoGenerationProvider());
|
||||
},
|
||||
});
|
||||
40
extensions/alibaba/openclaw.plugin.json
Normal file
40
extensions/alibaba/openclaw.plugin.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"id": "alibaba",
|
||||
"icon": "https://cdn.simpleicons.org/alibabacloud",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "alibaba",
|
||||
"envVars": ["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY", "QWEN_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "alibaba",
|
||||
"method": "api-key",
|
||||
"choiceId": "alibaba-model-studio-api-key",
|
||||
"choiceLabel": "Alibaba Model Studio API key",
|
||||
"groupId": "alibaba",
|
||||
"groupLabel": "Alibaba Model Studio",
|
||||
"groupHint": "DashScope / Model Studio API key",
|
||||
"onboardingScopes": ["image-generation"],
|
||||
"optionKey": "alibabaModelStudioApiKey",
|
||||
"cliFlag": "--alibaba-model-studio-api-key",
|
||||
"cliOption": "--alibaba-model-studio-api-key <key>",
|
||||
"cliDescription": "Alibaba Model Studio API key"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"videoGenerationProviders": ["alibaba"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
15
extensions/alibaba/package.json
Normal file
15
extensions/alibaba/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
16
extensions/alibaba/tsconfig.json
Normal file
16
extensions/alibaba/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
93
extensions/alibaba/video-generation-provider.test.ts
Normal file
93
extensions/alibaba/video-generation-provider.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Alibaba tests cover video generation provider plugin behavior.
|
||||
import {
|
||||
getProviderHttpMocks,
|
||||
installProviderHttpMockCleanup,
|
||||
} from "openclaw/plugin-sdk/provider-http-test-mocks";
|
||||
import {
|
||||
expectDashscopeVideoTaskPoll,
|
||||
expectExplicitVideoGenerationCapabilities,
|
||||
expectSuccessfulDashscopeVideoResult,
|
||||
mockSuccessfulDashscopeVideoTask,
|
||||
} from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
|
||||
|
||||
let buildAlibabaVideoGenerationProvider: typeof import("./video-generation-provider.js").buildAlibabaVideoGenerationProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildAlibabaVideoGenerationProvider } = await import("./video-generation-provider.js"));
|
||||
});
|
||||
|
||||
installProviderHttpMockCleanup();
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected ${label} to be a record`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireFirstPostJsonRequest(label: string): Record<string, unknown> {
|
||||
const [call] = postJsonRequestMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return requireRecord(call[0], label);
|
||||
}
|
||||
|
||||
describe("alibaba video generation provider", () => {
|
||||
it("declares explicit mode capabilities", () => {
|
||||
expectExplicitVideoGenerationCapabilities(buildAlibabaVideoGenerationProvider());
|
||||
});
|
||||
|
||||
it("submits async Wan generation, polls task status, and downloads the resulting video", async () => {
|
||||
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
|
||||
|
||||
const provider = buildAlibabaVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "alibaba",
|
||||
model: "wan2.6-r2v-flash",
|
||||
prompt: "animate this shot",
|
||||
cfg: {},
|
||||
inputImages: [{ url: "https://example.com/ref.png" }],
|
||||
durationSeconds: 6,
|
||||
audio: true,
|
||||
watermark: false,
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
const request = requireFirstPostJsonRequest("DashScope request");
|
||||
expect(request.url).toBe(
|
||||
"https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis",
|
||||
);
|
||||
const body = requireRecord(request.body, "DashScope request body");
|
||||
expect(body.model).toBe("wan2.6-r2v-flash");
|
||||
const input = requireRecord(body.input, "DashScope request input");
|
||||
expect(input.prompt).toBe("animate this shot");
|
||||
expect(input.img_url).toBe("https://example.com/ref.png");
|
||||
const parameters = requireRecord(body.parameters, "DashScope request parameters");
|
||||
expect(parameters.duration).toBe(6);
|
||||
expect(parameters.enable_audio).toBe(true);
|
||||
expect(parameters.watermark).toBe(false);
|
||||
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock);
|
||||
expectSuccessfulDashscopeVideoResult(result);
|
||||
});
|
||||
|
||||
it("fails fast when reference inputs are local buffers instead of remote URLs", async () => {
|
||||
const provider = buildAlibabaVideoGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "alibaba",
|
||||
model: "wan2.6-i2v",
|
||||
prompt: "animate this local frame",
|
||||
cfg: {},
|
||||
inputImages: [{ buffer: Buffer.from("png-bytes"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Alibaba Wan video generation currently requires remote http(s) URLs for reference images/videos.",
|
||||
);
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
88
extensions/alibaba/video-generation-provider.ts
Normal file
88
extensions/alibaba/video-generation-provider.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Alibaba Model Studio video provider adapter. It resolves DashScope auth and
|
||||
* HTTP policy before delegating task polling to the shared video helper.
|
||||
*/
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
DASHSCOPE_WAN_VIDEO_CAPABILITIES,
|
||||
DASHSCOPE_WAN_VIDEO_MODELS,
|
||||
DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
|
||||
DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
|
||||
runDashscopeVideoGenerationTask,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import type {
|
||||
VideoGenerationProvider,
|
||||
VideoGenerationRequest,
|
||||
VideoGenerationResult,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
|
||||
const DEFAULT_ALIBABA_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
|
||||
const DEFAULT_ALIBABA_VIDEO_MODEL = DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL;
|
||||
|
||||
function resolveAlibabaVideoBaseUrl(req: VideoGenerationRequest): string {
|
||||
return req.cfg?.models?.providers?.alibaba?.baseUrl?.trim() || DEFAULT_ALIBABA_VIDEO_BASE_URL;
|
||||
}
|
||||
|
||||
function resolveDashscopeAigcApiBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
/** Build the Alibaba/DashScope video generation provider descriptor. */
|
||||
export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
|
||||
return {
|
||||
id: "alibaba",
|
||||
label: "Alibaba Model Studio",
|
||||
defaultModel: DEFAULT_ALIBABA_VIDEO_MODEL,
|
||||
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: "alibaba",
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: DASHSCOPE_WAN_VIDEO_CAPABILITIES,
|
||||
async generateVideo(req): Promise<VideoGenerationResult> {
|
||||
const fetchFn = fetch;
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: "alibaba",
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("Alibaba Model Studio API key missing");
|
||||
}
|
||||
|
||||
const requestBaseUrl = resolveAlibabaVideoBaseUrl(req);
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: requestBaseUrl,
|
||||
defaultBaseUrl: DEFAULT_ALIBABA_VIDEO_BASE_URL,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-DashScope-Async": "enable",
|
||||
},
|
||||
provider: "alibaba",
|
||||
capability: "video",
|
||||
transport: "http",
|
||||
});
|
||||
|
||||
const model = req.model?.trim() || DEFAULT_ALIBABA_VIDEO_MODEL;
|
||||
return await runDashscopeVideoGenerationTask({
|
||||
providerLabel: "Alibaba Wan",
|
||||
model,
|
||||
req,
|
||||
url: `${resolveDashscopeAigcApiBaseUrl(baseUrl)}/api/v1/services/aigc/video-generation/video-synthesis`,
|
||||
headers,
|
||||
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
11
extensions/amazon-bedrock-mantle/README.md
Normal file
11
extensions/amazon-bedrock-mantle/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OpenClaw Amazon Bedrock Mantle Provider
|
||||
|
||||
Official OpenClaw provider plugin for routing Amazon Bedrock Mantle models through OpenAI-compatible provider flows.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugin add @openclaw/amazon-bedrock-mantle-provider
|
||||
```
|
||||
|
||||
Use this plugin when your Bedrock deployment exposes Mantle-compatible model routing and you want OpenClaw agents to address those models through the Bedrock Mantle provider.
|
||||
16
extensions/amazon-bedrock-mantle/api.ts
Normal file
16
extensions/amazon-bedrock-mantle/api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Public Amazon Bedrock Mantle API barrel for discovery and bearer-token
|
||||
* helpers shared by config, runtime, and tests.
|
||||
*/
|
||||
export {
|
||||
discoverMantleModels,
|
||||
generateBearerTokenFromIam,
|
||||
getCachedIamToken,
|
||||
MANTLE_IAM_TOKEN_MARKER,
|
||||
mergeImplicitMantleProvider,
|
||||
resetIamTokenCacheForTest,
|
||||
resetMantleDiscoveryCacheForTest,
|
||||
resolveImplicitMantleProvider,
|
||||
resolveMantleBearerToken,
|
||||
resolveMantleRuntimeBearerToken,
|
||||
} from "./discovery.js";
|
||||
693
extensions/amazon-bedrock-mantle/discovery.test.ts
Normal file
693
extensions/amazon-bedrock-mantle/discovery.test.ts
Normal file
@@ -0,0 +1,693 @@
|
||||
// Amazon Bedrock Mantle tests cover discovery plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
discoverMantleModels,
|
||||
generateBearerTokenFromIam,
|
||||
getCachedIamToken,
|
||||
MANTLE_IAM_TOKEN_MARKER,
|
||||
mergeImplicitMantleProvider,
|
||||
resetIamTokenCacheForTest,
|
||||
resetMantleDiscoveryCacheForTest,
|
||||
resolveImplicitMantleProvider,
|
||||
resolveMantleBearerToken,
|
||||
resolveMantleRuntimeBearerToken,
|
||||
} = await import("./api.js");
|
||||
|
||||
function createTokenProviderFactory(tokenProvider: () => Promise<string>) {
|
||||
return vi.fn(() => tokenProvider);
|
||||
}
|
||||
|
||||
type MockWithCalls = {
|
||||
mock: { calls: unknown[][] };
|
||||
};
|
||||
|
||||
function argAt(mock: MockWithCalls, callIndex: number, argIndex: number): unknown {
|
||||
const call = mock.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`expected call ${callIndex}`);
|
||||
}
|
||||
if (!(argIndex in call)) {
|
||||
throw new Error(`expected call ${callIndex} argument ${argIndex}`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
function objectArgAt(
|
||||
mock: MockWithCalls,
|
||||
callIndex: number,
|
||||
argIndex: number,
|
||||
): Record<string, unknown> {
|
||||
const value = argAt(mock, callIndex, argIndex);
|
||||
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected call ${callIndex} argument ${argIndex} to be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringArgAt(mock: MockWithCalls, callIndex: number, argIndex: number): string {
|
||||
const value = argAt(mock, callIndex, argIndex);
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`expected call ${callIndex} argument ${argIndex} to be a string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function recordField(value: unknown, field: string): Record<string, unknown> {
|
||||
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected ${field} to be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("bedrock mantle discovery", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
resetMantleDiscoveryCacheForTest();
|
||||
resetIamTokenCacheForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetMantleDiscoveryCacheForTest();
|
||||
resetIamTokenCacheForTest();
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bearer token resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("resolves bearer token from AWS_BEARER_TOKEN_BEDROCK", () => {
|
||||
expect(
|
||||
resolveMantleBearerToken({
|
||||
AWS_BEARER_TOKEN_BEDROCK: "bedrock-api-key-abc123", // pragma: allowlist secret
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("bedrock-api-key-abc123");
|
||||
});
|
||||
|
||||
it("returns undefined when no bearer token env var is set", () => {
|
||||
expect(resolveMantleBearerToken({} as NodeJS.ProcessEnv)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trims whitespace from bearer token", () => {
|
||||
expect(
|
||||
resolveMantleBearerToken({
|
||||
AWS_BEARER_TOKEN_BEDROCK: " my-token ", // pragma: allowlist secret
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("my-token");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IAM token generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("generates token from IAM credentials when token generation succeeds", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-generated"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
const token = await generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
tokenProviderFactory,
|
||||
});
|
||||
|
||||
expect(token).toBe("bedrock-api-key-generated");
|
||||
expect(tokenProviderFactory).toHaveBeenCalledWith({
|
||||
region: "us-east-1",
|
||||
expiresInSeconds: 7200,
|
||||
});
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("caches generated IAM tokens within TTL", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-cached"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
let now = 1000;
|
||||
|
||||
const t1 = await generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => now,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
now += 1800_000; // 30 min — within 2hr cache TTL
|
||||
const t2 = await generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => now,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
|
||||
expect(t1).toEqual(t2);
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reuse an IAM token across regions", async () => {
|
||||
const tokenProvider = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce("bedrock-api-key-east") // pragma: allowlist secret
|
||||
.mockResolvedValueOnce("bedrock-api-key-west"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
const east = await generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => 1000,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
const west = await generateBearerTokenFromIam({
|
||||
region: "us-west-2",
|
||||
now: () => 2000,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
|
||||
expect(east).toBe("bedrock-api-key-east");
|
||||
expect(west).toBe("bedrock-api-key-west");
|
||||
expect(tokenProviderFactory).toHaveBeenNthCalledWith(1, {
|
||||
region: "us-east-1",
|
||||
expiresInSeconds: 7200,
|
||||
});
|
||||
expect(tokenProviderFactory).toHaveBeenNthCalledWith(2, {
|
||||
region: "us-west-2",
|
||||
expiresInSeconds: 7200,
|
||||
});
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns undefined when IAM token generation fails", async () => {
|
||||
const tokenProviderFactory = vi.fn(() => {
|
||||
throw new Error("no credentials");
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips IAM token generation when plugin discovery is disabled", async () => {
|
||||
const tokenProviderFactory = vi.fn(() => {
|
||||
throw new Error("disabled discovery should not generate a token");
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveImplicitMantleProvider({
|
||||
env: { AWS_REGION: "us-east-1" } as NodeJS.ProcessEnv,
|
||||
pluginConfig: { discovery: { enabled: false } },
|
||||
tokenProviderFactory,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(tokenProviderFactory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getCachedIamToken returns cached token when valid", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-cached-token"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
// Generate a token to populate the cache
|
||||
await generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory });
|
||||
|
||||
// Sync read should return the cached token
|
||||
expect(getCachedIamToken("us-east-1")).toBe("bedrock-cached-token");
|
||||
});
|
||||
|
||||
it("getCachedIamToken returns undefined when cache is empty", () => {
|
||||
expect(getCachedIamToken("us-east-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getCachedIamToken returns undefined when cache is expired", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-expired-token"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
// Generate with a time far in the past so it's already expired
|
||||
await generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => 1000,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
|
||||
// The cache entry exists but expiresAt is 1000 + 3600000 = 3601000
|
||||
// Current Date.now() is way past that, so it should be expired
|
||||
expect(getCachedIamToken("us-east-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not cache generated IAM tokens when ttl expiry overflows", async () => {
|
||||
const tokenProvider = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce("bedrock-overflow-token-1") // pragma: allowlist secret
|
||||
.mockResolvedValueOnce("bedrock-overflow-token-2"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
await expect(
|
||||
generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => 8_640_000_000_000_000,
|
||||
tokenProviderFactory,
|
||||
}),
|
||||
).resolves.toBe("bedrock-overflow-token-1");
|
||||
expect(getCachedIamToken("us-east-1")).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => 8_640_000_000_000_000,
|
||||
tokenProviderFactory,
|
||||
}),
|
||||
).resolves.toBe("bedrock-overflow-token-2");
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("discovers models from Mantle /v1/models endpoint sorted by id", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "openai.gpt-oss-120b", object: "model", owned_by: "openai" },
|
||||
{ id: "anthropic.claude-sonnet-4-6", object: "model", owned_by: "anthropic" },
|
||||
{ id: "mistral.devstral-2-123b", object: "model", owned_by: "mistral" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toHaveLength(3);
|
||||
// Models should be sorted alphabetically by id
|
||||
expect(models[0]?.id).toBe("anthropic.claude-sonnet-4-6");
|
||||
expect(models[0]?.name).toBe("anthropic.claude-sonnet-4-6");
|
||||
expect(models[0]?.reasoning).toBe(false);
|
||||
expect(models[0]?.input).toEqual(["text"]);
|
||||
expect(models[1]?.id).toBe("mistral.devstral-2-123b");
|
||||
expect(models[1]?.reasoning).toBe(false);
|
||||
expect(models[2]?.id).toBe("openai.gpt-oss-120b");
|
||||
expect(models[2]?.reasoning).toBe(true); // GPT-OSS 120B supports reasoning
|
||||
|
||||
// Verify correct endpoint and auth header
|
||||
expect(stringArgAt(mockFetch, 0, 0)).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/models");
|
||||
expect(recordField(objectArgAt(mockFetch, 0, 1).headers, "headers").Authorization).toBe(
|
||||
"Bearer test-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("infers reasoning support from model IDs", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "moonshotai.kimi-k2-thinking", object: "model" },
|
||||
{ id: "openai.gpt-oss-120b", object: "model" },
|
||||
{ id: "openai.gpt-oss-safeguard-120b", object: "model" },
|
||||
{ id: "deepseek.v3.2", object: "model" },
|
||||
{ id: "mistral.mistral-large-3-675b-instruct", object: "model" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
const byId = Object.fromEntries(models.map((m) => [m.id, m]));
|
||||
expect(byId["moonshotai.kimi-k2-thinking"]?.reasoning).toBe(true);
|
||||
expect(byId["openai.gpt-oss-120b"]?.reasoning).toBe(true);
|
||||
expect(byId["openai.gpt-oss-safeguard-120b"]?.reasoning).toBe(true);
|
||||
expect(byId["deepseek.v3.2"]?.reasoning).toBe(false);
|
||||
expect(byId["mistral.mistral-large-3-675b-instruct"]?.reasoning).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty array on permission error", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array on network error", async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("filters out models with empty IDs", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "anthropic.claude-sonnet-4-6", object: "model" },
|
||||
{ id: "", object: "model" },
|
||||
{ id: " ", object: "model" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0]?.id).toBe("anthropic.claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discovery caching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("returns cached models on subsequent calls within refresh interval", async () => {
|
||||
let now = 1000000;
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
||||
}),
|
||||
});
|
||||
|
||||
// First call — hits the network
|
||||
const first = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(first).toHaveLength(1);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call within refresh interval — uses cache
|
||||
now += 60_000; // 1 minute later
|
||||
const second = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(second).toHaveLength(1);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1); // No additional fetch
|
||||
|
||||
// Third call after refresh interval — re-fetches
|
||||
now += 3600_000; // 1 hour later
|
||||
const third = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(third).toHaveLength(1);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2); // Re-fetched
|
||||
});
|
||||
|
||||
it("returns stale cache on fetch failure", async () => {
|
||||
let now = 1000000;
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
||||
}),
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
||||
|
||||
// First call — succeeds
|
||||
await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
// Second call after expiry — fails but returns stale cache
|
||||
now += 7200_000;
|
||||
const stale = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(stale).toHaveLength(1);
|
||||
expect(stale[0]?.id).toBe("anthropic.claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implicit provider resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("resolves implicit provider when bearer token is set", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
||||
}),
|
||||
});
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
||||
AWS_REGION: "us-east-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1");
|
||||
expect(provider?.api).toBe("openai-completions");
|
||||
expect(provider?.auth).toBe("api-key");
|
||||
expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK");
|
||||
expect(provider?.models).toHaveLength(3);
|
||||
const opus = provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7");
|
||||
expect(opus?.api).toBe("anthropic-messages");
|
||||
expect(opus?.reasoning).toBe(false);
|
||||
expect(opus).not.toHaveProperty("baseUrl");
|
||||
const mythos = provider?.models?.find(
|
||||
(model) => model.id === "anthropic.claude-mythos-preview",
|
||||
);
|
||||
expect(mythos).toMatchObject({
|
||||
api: "anthropic-messages",
|
||||
reasoning: true,
|
||||
params: { canonicalModelId: "claude-mythos-preview" },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no auth is available", async () => {
|
||||
const tokenProviderFactory = vi.fn(() => {
|
||||
throw new Error("no credentials");
|
||||
});
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
|
||||
expect(provider).toBeNull();
|
||||
});
|
||||
|
||||
it("uses a generated IAM token when no explicit token is set", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-iam"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "openai.gpt-oss-120b", object: "model" }],
|
||||
}),
|
||||
});
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_PROFILE: "default",
|
||||
AWS_REGION: "us-east-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
|
||||
expect(provider?.apiKey).toBe(MANTLE_IAM_TOKEN_MARKER);
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
expect(stringArgAt(mockFetch, 0, 0)).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/models");
|
||||
expect(recordField(objectArgAt(mockFetch, 0, 1).headers, "headers").Authorization).toBe(
|
||||
"Bearer bedrock-api-key-iam",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves Mantle runtime auth from the cached IAM token marker", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-runtime"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
await generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => 1000,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
|
||||
const resolved = await resolveMantleRuntimeBearerToken({
|
||||
apiKey: MANTLE_IAM_TOKEN_MARKER,
|
||||
env: {
|
||||
AWS_REGION: "us-east-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
now: () => 2000,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
expect(resolved?.apiKey).toBe("bedrock-api-key-runtime");
|
||||
expect(resolved?.expiresAt).toBe(1000 + 7200_000);
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("generates a fresh Mantle runtime IAM token when the cache is cold", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-fresh"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
const resolved = await resolveMantleRuntimeBearerToken({
|
||||
apiKey: MANTLE_IAM_TOKEN_MARKER,
|
||||
env: {
|
||||
AWS_REGION: "us-east-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
now: () => 5000,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
expect(resolved?.apiKey).toBe("bedrock-api-key-fresh");
|
||||
expect(resolved?.expiresAt).toBe(5000 + 7200_000);
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("omits Mantle runtime IAM token expiry when the process clock is invalid", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-invalid-clock"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
const resolved = await resolveMantleRuntimeBearerToken({
|
||||
apiKey: MANTLE_IAM_TOKEN_MARKER,
|
||||
env: {
|
||||
AWS_REGION: "us-east-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
now: () => Number.NaN,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
apiKey: "bedrock-api-key-invalid-clock",
|
||||
});
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null for unsupported regions", async () => {
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
||||
AWS_REGION: "af-south-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(provider).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to us-east-1 when no region is set", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "openai.gpt-oss-120b", object: "model" }] }),
|
||||
});
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1");
|
||||
expect(stringArgAt(mockFetch, 0, 0)).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/models");
|
||||
objectArgAt(mockFetch, 0, 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider merging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("merges implicit models when existing provider has empty models", () => {
|
||||
const result = mergeImplicitMantleProvider({
|
||||
existing: {
|
||||
baseUrl: "https://custom.example.com/v1",
|
||||
models: [],
|
||||
},
|
||||
implicit: {
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
||||
api: "openai-completions",
|
||||
auth: "api-key",
|
||||
apiKey: "env:AWS_BEARER_TOKEN_BEDROCK",
|
||||
models: [
|
||||
{
|
||||
id: "openai.gpt-oss-120b",
|
||||
name: "GPT-OSS 120B",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.baseUrl).toBe("https://custom.example.com/v1");
|
||||
expect(result.models?.map((m) => m.id)).toEqual(["openai.gpt-oss-120b"]);
|
||||
});
|
||||
|
||||
it("preserves existing models over implicit ones", () => {
|
||||
const result = mergeImplicitMantleProvider({
|
||||
existing: {
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
||||
models: [
|
||||
{
|
||||
id: "custom-model",
|
||||
name: "My Custom Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 64000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
implicit: {
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
||||
api: "openai-completions",
|
||||
auth: "api-key",
|
||||
models: [
|
||||
{
|
||||
id: "openai.gpt-oss-120b",
|
||||
name: "GPT-OSS 120B",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.models?.map((m) => m.id)).toEqual(["custom-model"]);
|
||||
});
|
||||
});
|
||||
447
extensions/amazon-bedrock-mantle/discovery.ts
Normal file
447
extensions/amazon-bedrock-mantle/discovery.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Amazon Bedrock Mantle discovery and bearer-token handling. It resolves
|
||||
* explicit tokens, IAM-generated tokens, model catalogs, and implicit provider config.
|
||||
*/
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const log = createSubsystemLogger("bedrock-mantle-discovery");
|
||||
|
||||
const DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 32000;
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600; // 1 hour
|
||||
/** Config auth marker meaning Mantle should mint runtime bearer tokens from IAM. */
|
||||
export const MANTLE_IAM_TOKEN_MARKER = "__amazon_bedrock_mantle_iam__";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mantle region & endpoint helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MANTLE_SUPPORTED_REGIONS = [
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2",
|
||||
"ap-northeast-1",
|
||||
"ap-south-1",
|
||||
"ap-southeast-3",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-south-1",
|
||||
"eu-north-1",
|
||||
"sa-east-1",
|
||||
] as const;
|
||||
|
||||
function mantleEndpoint(region: string): string {
|
||||
return `https://bedrock-mantle.${region}.api.aws`;
|
||||
}
|
||||
|
||||
function isSupportedRegion(region: string): boolean {
|
||||
return (MANTLE_SUPPORTED_REGIONS as readonly string[]).includes(region);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bearer token resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MantleBearerTokenProvider = () => Promise<string>;
|
||||
type MantleBearerTokenProviderFactory = (opts?: {
|
||||
region?: string;
|
||||
expiresInSeconds?: number;
|
||||
}) => MantleBearerTokenProvider;
|
||||
|
||||
async function loadMantleBearerTokenProviderFactory(): Promise<MantleBearerTokenProviderFactory> {
|
||||
const { getTokenProvider } = (await import("@aws/bedrock-token-generator")) as {
|
||||
getTokenProvider: MantleBearerTokenProviderFactory;
|
||||
};
|
||||
return getTokenProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bearer token for Mantle authentication.
|
||||
*
|
||||
* Returns the value of AWS_BEARER_TOKEN_BEDROCK if set, undefined otherwise.
|
||||
* When no explicit token is set, `resolveImplicitMantleProvider` will attempt
|
||||
* to generate one from IAM credentials via `@aws/bedrock-token-generator`.
|
||||
*/
|
||||
export function resolveMantleBearerToken(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const explicitToken = env.AWS_BEARER_TOKEN_BEDROCK?.trim();
|
||||
if (explicitToken) {
|
||||
return explicitToken;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Token cache for IAM-derived bearer tokens, keyed by region. */
|
||||
const iamTokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||
const IAM_TOKEN_TTL_MS = 7200_000; // Matches the 2h token lifetime we request below.
|
||||
|
||||
function resolveMantleRegion(env: NodeJS.ProcessEnv): string {
|
||||
return env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
|
||||
}
|
||||
|
||||
function getCachedIamTokenEntry(
|
||||
region: string,
|
||||
now: number = Date.now(),
|
||||
): { token: string; expiresAt: number } | undefined {
|
||||
const cached = iamTokenCache.get(region);
|
||||
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
|
||||
return cached;
|
||||
}
|
||||
iamTokenCache.delete(region);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a bearer token from IAM credentials using `@aws/bedrock-token-generator`.
|
||||
*
|
||||
* Uses the AWS default credential chain (instance roles, SSO, access keys, EKS IRSA).
|
||||
* Returns undefined if the package is not installed or credentials are unavailable.
|
||||
*/
|
||||
export async function generateBearerTokenFromIam(params: {
|
||||
region: string;
|
||||
now?: () => number;
|
||||
tokenProviderFactory?: MantleBearerTokenProviderFactory;
|
||||
}): Promise<string | undefined> {
|
||||
const now = params.now?.() ?? Date.now();
|
||||
const cached = getCachedIamTokenEntry(params.region, now);
|
||||
|
||||
if (cached) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
try {
|
||||
const getTokenProvider =
|
||||
params.tokenProviderFactory ?? (await loadMantleBearerTokenProviderFactory());
|
||||
const token = await getTokenProvider({
|
||||
region: params.region,
|
||||
expiresInSeconds: 7200, // 2 hours
|
||||
})();
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
|
||||
if (expiresAt !== undefined) {
|
||||
iamTokenCache.set(params.region, { token, expiresAt });
|
||||
}
|
||||
return token;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle IAM token generation unavailable", {
|
||||
region: params.region,
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a cached IAM bearer token for the given region (sync, no generation).
|
||||
*
|
||||
* Returns the token if it exists and has not expired, undefined otherwise.
|
||||
* Used by Mantle runtime auth and tests to inspect the current cache.
|
||||
*/
|
||||
export function getCachedIamToken(region: string): string | undefined {
|
||||
return getCachedIamTokenEntry(region)?.token;
|
||||
}
|
||||
|
||||
/** Resolve the actual runtime bearer token for Mantle, generating IAM tokens when needed. */
|
||||
export async function resolveMantleRuntimeBearerToken(params: {
|
||||
apiKey: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: () => number;
|
||||
tokenProviderFactory?: MantleBearerTokenProviderFactory;
|
||||
}): Promise<{ apiKey: string; expiresAt?: number } | undefined> {
|
||||
if (params.apiKey !== MANTLE_IAM_TOKEN_MARKER) {
|
||||
return { apiKey: params.apiKey };
|
||||
}
|
||||
const now = params.now?.() ?? Date.now();
|
||||
const region = resolveMantleRegion(params.env ?? process.env);
|
||||
const cached = getCachedIamTokenEntry(region, now);
|
||||
if (cached) {
|
||||
return {
|
||||
apiKey: cached.token,
|
||||
expiresAt: cached.expiresAt,
|
||||
};
|
||||
}
|
||||
const token = await generateBearerTokenFromIam({
|
||||
region,
|
||||
now: params.now,
|
||||
tokenProviderFactory: params.tokenProviderFactory,
|
||||
});
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
const refreshed = getCachedIamTokenEntry(region, now);
|
||||
const expiresAt =
|
||||
refreshed?.expiresAt ?? resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
|
||||
return {
|
||||
apiKey: refreshed?.token ?? token,
|
||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||
};
|
||||
}
|
||||
/** Clear the IAM token cache for tests. */
|
||||
export function resetIamTokenCacheForTest(): void {
|
||||
iamTokenCache.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI-format model list response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OpenAIModelEntry {
|
||||
id: string;
|
||||
object?: string;
|
||||
owned_by?: string;
|
||||
created?: number;
|
||||
}
|
||||
|
||||
interface OpenAIModelsResponse {
|
||||
data?: OpenAIModelEntry[];
|
||||
object?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reasoning heuristic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Model ID substrings that indicate reasoning/thinking support. */
|
||||
const REASONING_PATTERNS = [
|
||||
"thinking",
|
||||
"reasoner",
|
||||
"reasoning",
|
||||
"deepseek.r",
|
||||
"gpt-oss-120b", // GPT-OSS 120B supports reasoning
|
||||
"gpt-oss-safeguard-120b",
|
||||
];
|
||||
|
||||
function inferReasoningSupport(modelId: string): boolean {
|
||||
const lower = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return REASONING_PATTERNS.some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discovery cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MantleCacheEntry {
|
||||
models: ModelDefinitionConfig[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
type MantleDiscoveryConfig = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const discoveryCache = new Map<string, MantleCacheEntry>();
|
||||
|
||||
/** Clear the Mantle discovery cache for tests. */
|
||||
export function resetMantleDiscoveryCacheForTest(): void {
|
||||
discoveryCache.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover available models from the Mantle `/v1/models` endpoint.
|
||||
*
|
||||
* The response is in standard OpenAI format:
|
||||
* ```json
|
||||
* { "data": [{ "id": "anthropic.claude-sonnet-4-6", "object": "model", "owned_by": "anthropic" }] }
|
||||
* ```
|
||||
*
|
||||
* Results are cached per region for `DEFAULT_REFRESH_INTERVAL_SECONDS`.
|
||||
* Returns an empty array if the request fails (no permission, network error, etc.).
|
||||
*/
|
||||
/** Discover Mantle models for one region/config. */
|
||||
export async function discoverMantleModels(params: {
|
||||
region: string;
|
||||
bearerToken: string;
|
||||
fetchFn?: typeof fetch;
|
||||
now?: () => number;
|
||||
}): Promise<ModelDefinitionConfig[]> {
|
||||
const { region, bearerToken, fetchFn = fetch, now = Date.now } = params;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = region;
|
||||
const cached = discoveryCache.get(cacheKey);
|
||||
if (cached && now() - cached.fetchedAt < DEFAULT_REFRESH_INTERVAL_SECONDS * 1000) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
const endpoint = `${mantleEndpoint(region)}/v1/models`;
|
||||
|
||||
try {
|
||||
const response = await fetchFn(endpoint, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug?.("Mantle model discovery failed", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return cached?.models ?? [];
|
||||
}
|
||||
|
||||
const body = (await response.json()) as OpenAIModelsResponse;
|
||||
const rawModels = body.data ?? [];
|
||||
|
||||
const models = rawModels
|
||||
.filter((m) => m.id?.trim())
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.id, // Mantle doesn't return display names
|
||||
reasoning: inferReasoningSupport(m.id),
|
||||
input: ["text" as const],
|
||||
cost: DEFAULT_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
}))
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
discoveryCache.set(cacheKey, { models, fetchedAt: now() });
|
||||
return models;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle model discovery error", {
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return cached?.models ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implicit provider resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve an implicit Bedrock Mantle provider if authentication is available.
|
||||
*
|
||||
* Detection priority:
|
||||
* 1. AWS_BEARER_TOKEN_BEDROCK env var → use directly
|
||||
* 2. IAM credentials → generate bearer token via `@aws/bedrock-token-generator`
|
||||
* - Region from AWS_REGION / AWS_DEFAULT_REGION / default us-east-1
|
||||
* - Models discovered from `/v1/models`
|
||||
*/
|
||||
/** Resolve implicit Mantle provider config from env, IAM token support, and discovery. */
|
||||
export async function resolveImplicitMantleProvider(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pluginConfig?: { discovery?: MantleDiscoveryConfig };
|
||||
fetchFn?: typeof fetch;
|
||||
tokenProviderFactory?: MantleBearerTokenProviderFactory;
|
||||
}): Promise<ModelProviderConfig | null> {
|
||||
const env = params.env ?? process.env;
|
||||
if (params.pluginConfig?.discovery?.enabled === false) {
|
||||
return null;
|
||||
}
|
||||
const region = resolveMantleRegion(env);
|
||||
const explicitBearerToken = resolveMantleBearerToken(env);
|
||||
|
||||
if (!isSupportedRegion(region)) {
|
||||
log.debug?.("Mantle not available in region", { region });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try explicit token first, then generate from IAM credentials
|
||||
const bearerToken =
|
||||
explicitBearerToken ??
|
||||
(await generateBearerTokenFromIam({
|
||||
region,
|
||||
tokenProviderFactory: params.tokenProviderFactory,
|
||||
}));
|
||||
|
||||
if (!bearerToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region,
|
||||
bearerToken,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
if (models.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug?.("Mantle provider resolved", { region, modelCount: models.length });
|
||||
|
||||
// Append Claude models available on Mantle's Anthropic Messages endpoint.
|
||||
// Opus 4.7 currently needs the provider-owned bearer-auth path here, but we
|
||||
// keep reasoning off until the underlying Anthropic transport learns Opus 4.7
|
||||
// adaptive thinking semantics.
|
||||
const claudeModels: ModelDefinitionConfig[] = [
|
||||
{
|
||||
id: "anthropic.claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
api: "anthropic-messages" as const,
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
{
|
||||
id: "anthropic.claude-mythos-preview",
|
||||
name: "Claude Mythos Preview",
|
||||
api: "anthropic-messages" as const,
|
||||
reasoning: true,
|
||||
params: { canonicalModelId: "claude-mythos-preview" },
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
];
|
||||
const allModels = [...models, ...claudeModels];
|
||||
|
||||
return {
|
||||
baseUrl: `${mantleEndpoint(region)}/v1`,
|
||||
api: "openai-completions",
|
||||
auth: "api-key",
|
||||
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : MANTLE_IAM_TOKEN_MARKER,
|
||||
models: allModels,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge an implicit Mantle provider catalog with explicit user config. */
|
||||
export function mergeImplicitMantleProvider(params: {
|
||||
existing: ModelProviderConfig | undefined;
|
||||
implicit: ModelProviderConfig;
|
||||
}): ModelProviderConfig {
|
||||
const { existing, implicit } = params;
|
||||
if (!existing) {
|
||||
return implicit;
|
||||
}
|
||||
return {
|
||||
...implicit,
|
||||
...existing,
|
||||
models:
|
||||
Array.isArray(existing.models) && existing.models.length > 0
|
||||
? existing.models
|
||||
: implicit.models,
|
||||
};
|
||||
}
|
||||
88
extensions/amazon-bedrock-mantle/index.test.ts
Normal file
88
extensions/amazon-bedrock-mantle/index.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Amazon Bedrock Mantle tests cover index plugin behavior.
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import bedrockMantlePlugin from "./index.js";
|
||||
|
||||
describe("amazon-bedrock-mantle provider plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses live plugin config to disable catalog discovery", async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockRejectedValue(new Error("unexpected fetch"));
|
||||
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
|
||||
const catalog = provider.catalog;
|
||||
if (!catalog) {
|
||||
throw new Error("catalog registration missing");
|
||||
}
|
||||
|
||||
const result = await catalog.run({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"amazon-bedrock-mantle": {
|
||||
config: {
|
||||
discovery: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "test-token",
|
||||
AWS_REGION: "us-east-1",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers with correct provider ID and label", async () => {
|
||||
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
|
||||
expect(provider.id).toBe("amazon-bedrock-mantle");
|
||||
expect(provider.label).toBe("Amazon Bedrock Mantle (OpenAI-compatible)");
|
||||
});
|
||||
|
||||
it("classifies rate limit errors for failover", async () => {
|
||||
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
|
||||
expect(
|
||||
provider.classifyFailoverReason?.({ errorMessage: "rate_limit exceeded" } as never),
|
||||
).toBe("rate_limit");
|
||||
expect(
|
||||
provider.classifyFailoverReason?.({ errorMessage: "429 Too Many Requests" } as never),
|
||||
).toBe("rate_limit");
|
||||
expect(
|
||||
provider.classifyFailoverReason?.({ errorMessage: "some other error" } as never),
|
||||
).toBeUndefined();
|
||||
expect(provider.classifyFailoverReason?.({ errorMessage: "overloaded_error" } as never)).toBe(
|
||||
"overloaded",
|
||||
);
|
||||
});
|
||||
|
||||
it("provides a custom stream only for Mantle Anthropic models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
|
||||
|
||||
expect(
|
||||
typeof provider.createStreamFn?.({
|
||||
provider: "amazon-bedrock-mantle",
|
||||
modelId: "anthropic.claude-opus-4-7",
|
||||
model: {
|
||||
api: "anthropic-messages",
|
||||
},
|
||||
} as never),
|
||||
).toBe("function");
|
||||
|
||||
expect(
|
||||
provider.createStreamFn?.({
|
||||
provider: "amazon-bedrock-mantle",
|
||||
modelId: "openai.gpt-oss-120b",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
},
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
15
extensions/amazon-bedrock-mantle/index.ts
Normal file
15
extensions/amazon-bedrock-mantle/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Amazon Bedrock Mantle plugin entry. Registers the OpenAI-compatible Mantle
|
||||
* provider plus Anthropic stream compatibility hooks.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerBedrockMantlePlugin } from "./register.sync.runtime.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "amazon-bedrock-mantle",
|
||||
name: "Amazon Bedrock Mantle Provider",
|
||||
description: "Bundled Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
register(api) {
|
||||
registerBedrockMantlePlugin(api);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
// Amazon Bedrock Mantle tests cover mantle anthropic plugin behavior.
|
||||
import type { Model } from "openclaw/plugin-sdk/llm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMantleAnthropicStreamFn,
|
||||
resolveMantleAnthropicBaseUrl,
|
||||
} from "./mantle-anthropic.runtime.js";
|
||||
|
||||
function createTestModel(overrides: Partial<Model> = {}): Model {
|
||||
return {
|
||||
id: "anthropic.claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
provider: "amazon-bedrock-mantle",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
||||
headers: {
|
||||
"X-Test": "model-header",
|
||||
},
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
...overrides,
|
||||
} as Model;
|
||||
}
|
||||
|
||||
function createTestDeps() {
|
||||
return {
|
||||
createClient: vi.fn((options: unknown) => ({ options }) as never),
|
||||
stream: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`Expected ${label} to be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0): unknown {
|
||||
const call = mock.mock.calls[index];
|
||||
if (!call) {
|
||||
throw new Error(`expected mock call ${index}`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
function expectFirstStreamCall(
|
||||
deps: ReturnType<typeof createTestDeps>,
|
||||
model: Model,
|
||||
context: unknown,
|
||||
) {
|
||||
expect(mockCallArg(deps.stream, 0, 0)).toBe(model);
|
||||
expect(mockCallArg(deps.stream, 0, 1)).toBe(context);
|
||||
}
|
||||
|
||||
function firstStreamOptions(deps: ReturnType<typeof createTestDeps>): Record<string, unknown> {
|
||||
return requireRecord(mockCallArg(deps.stream, 0, 2), "stream options");
|
||||
}
|
||||
|
||||
describe("createMantleAnthropicStreamFn", () => {
|
||||
it("uses authToken bearer auth for Mantle Anthropic requests", () => {
|
||||
const stream = { kind: "anthropic-stream" };
|
||||
const model = createTestModel();
|
||||
const context = { messages: [] };
|
||||
const deps = createTestDeps();
|
||||
deps.stream.mockReturnValue(stream as never);
|
||||
|
||||
const result = createMantleAnthropicStreamFn(deps)(model, context, {
|
||||
apiKey: "bedrock-bearer-token",
|
||||
headers: {
|
||||
"X-Caller": "caller-header",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(stream);
|
||||
const clientOptions = requireRecord(mockCallArg(deps.createClient), "client options");
|
||||
expect(clientOptions.apiKey).toBeNull();
|
||||
expect(clientOptions.authToken).toBe("bedrock-bearer-token");
|
||||
expect(clientOptions.baseURL).toBe("https://bedrock-mantle.us-east-1.api.aws/anthropic");
|
||||
const defaultHeaders = requireRecord(clientOptions.defaultHeaders, "default headers");
|
||||
expect(defaultHeaders.accept).toBe("application/json");
|
||||
expect(defaultHeaders["anthropic-beta"]).toBe("fine-grained-tool-streaming-2025-05-14");
|
||||
expect(defaultHeaders["X-Test"]).toBe("model-header");
|
||||
expect(defaultHeaders["X-Caller"]).toBe("caller-header");
|
||||
|
||||
expectFirstStreamCall(deps, model, context);
|
||||
const streamOptions = firstStreamOptions(deps);
|
||||
const client = requireRecord(streamOptions.client, "stream client");
|
||||
expect(requireRecord(client.options, "stream client options").authToken).toBe(
|
||||
"bedrock-bearer-token",
|
||||
);
|
||||
expect(streamOptions.thinkingEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("omits unsupported Opus 4.7 sampling and reasoning overrides", () => {
|
||||
const model = createTestModel();
|
||||
const context = { messages: [] };
|
||||
const deps = createTestDeps();
|
||||
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
|
||||
|
||||
void createMantleAnthropicStreamFn(deps)(model, context, {
|
||||
apiKey: "bedrock-bearer-token",
|
||||
temperature: 0.2,
|
||||
reasoning: "high",
|
||||
});
|
||||
|
||||
expectFirstStreamCall(deps, model, context);
|
||||
const streamOptions = firstStreamOptions(deps);
|
||||
expect(streamOptions.temperature).toBeUndefined();
|
||||
expect(streamOptions.thinkingEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults Mythos Preview to adaptive high effort", () => {
|
||||
const model = createTestModel({
|
||||
id: "anthropic.claude-mythos-preview",
|
||||
name: "Claude Mythos Preview",
|
||||
reasoning: true,
|
||||
params: { canonicalModelId: "claude-mythos-preview" },
|
||||
});
|
||||
const context = { messages: [] };
|
||||
const deps = createTestDeps();
|
||||
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
|
||||
|
||||
void createMantleAnthropicStreamFn(deps)(model, context, {
|
||||
apiKey: "bedrock-bearer-token",
|
||||
});
|
||||
|
||||
expectFirstStreamCall(deps, model, context);
|
||||
const streamOptions = firstStreamOptions(deps);
|
||||
expect(streamOptions.thinkingEnabled).toBe(true);
|
||||
expect(streamOptions.effort).toBe("high");
|
||||
});
|
||||
|
||||
it("clamps unsupported Mythos Preview max effort to high", () => {
|
||||
const model = createTestModel({
|
||||
id: "anthropic.claude-mythos-preview",
|
||||
name: "Claude Mythos Preview",
|
||||
reasoning: true,
|
||||
params: { canonicalModelId: "claude-mythos-preview" },
|
||||
});
|
||||
const context = { messages: [] };
|
||||
const deps = createTestDeps();
|
||||
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
|
||||
|
||||
void createMantleAnthropicStreamFn(deps)(model, context, {
|
||||
apiKey: "bedrock-bearer-token",
|
||||
reasoning: "max",
|
||||
});
|
||||
|
||||
expectFirstStreamCall(deps, model, context);
|
||||
const streamOptions = firstStreamOptions(deps);
|
||||
expect(streamOptions.thinkingEnabled).toBe(true);
|
||||
expect(streamOptions.effort).toBe("high");
|
||||
});
|
||||
|
||||
it("maps Mythos Preview minimal reasoning to low effort", () => {
|
||||
const model = createTestModel({
|
||||
id: "anthropic.claude-mythos-preview",
|
||||
name: "Claude Mythos Preview",
|
||||
reasoning: true,
|
||||
params: { canonicalModelId: "claude-mythos-preview" },
|
||||
});
|
||||
const deps = createTestDeps();
|
||||
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
|
||||
|
||||
void createMantleAnthropicStreamFn(deps)(model, { messages: [] }, {
|
||||
apiKey: "bedrock-bearer-token",
|
||||
reasoning: "minimal",
|
||||
});
|
||||
|
||||
const streamOptions = firstStreamOptions(deps);
|
||||
expect(streamOptions.thinkingEnabled).toBe(true);
|
||||
expect(streamOptions.effort).toBe("low");
|
||||
});
|
||||
|
||||
it("normalizes Mantle provider URLs to the Anthropic endpoint", () => {
|
||||
expect(resolveMantleAnthropicBaseUrl("https://bedrock-mantle.us-east-1.api.aws/v1")).toBe(
|
||||
"https://bedrock-mantle.us-east-1.api.aws/anthropic",
|
||||
);
|
||||
expect(
|
||||
resolveMantleAnthropicBaseUrl("https://bedrock-mantle.us-east-1.api.aws/anthropic/"),
|
||||
).toBe("https://bedrock-mantle.us-east-1.api.aws/anthropic");
|
||||
});
|
||||
});
|
||||
165
extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts
Normal file
165
extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Anthropic Messages stream adapter for Bedrock Mantle. It rewrites Mantle
|
||||
* endpoints to Anthropic-compatible URLs and adjusts thinking-token budgets.
|
||||
*/
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import { stream, type Model, type SimpleStreamOptions } from "openclaw/plugin-sdk/llm";
|
||||
|
||||
const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14";
|
||||
type AnthropicOptions = ConstructorParameters<typeof Anthropic>[0];
|
||||
type MantleAnthropicStream = typeof stream;
|
||||
|
||||
/** Resolve the Anthropic-compatible Mantle base URL from a provider base URL. */
|
||||
export function resolveMantleAnthropicBaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
if (trimmed.endsWith("/anthropic")) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.endsWith("/v1")) {
|
||||
return `${trimmed.slice(0, -"/v1".length)}/anthropic`;
|
||||
}
|
||||
return `${trimmed}/anthropic`;
|
||||
}
|
||||
|
||||
function requiresDefaultSampling(modelId: string): boolean {
|
||||
return modelId.includes("claude-opus-4-7");
|
||||
}
|
||||
|
||||
function isClaudeMythosPreviewModel(model: Model): boolean {
|
||||
return [model.id, model.name, model.params?.canonicalModelId]
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.some((value) =>
|
||||
/(?:^|-)claude-mythos-preview(?=$|[^a-z0-9])/.test(
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_.:]+/g, "-"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMantleReasoning(
|
||||
model: Model,
|
||||
options: SimpleStreamOptions | undefined,
|
||||
): NonNullable<SimpleStreamOptions["reasoning"]> | undefined {
|
||||
if (requiresDefaultSampling(model.id)) {
|
||||
return undefined;
|
||||
}
|
||||
const reasoning = options?.reasoning ?? (isClaudeMythosPreviewModel(model) ? "high" : undefined);
|
||||
if (!isClaudeMythosPreviewModel(model)) {
|
||||
return reasoning;
|
||||
}
|
||||
if (reasoning === "minimal") {
|
||||
return "low";
|
||||
}
|
||||
return reasoning === "xhigh" || reasoning === "max" ? "high" : reasoning;
|
||||
}
|
||||
|
||||
function mergeHeaders(
|
||||
...headerSources: Array<Record<string, string> | undefined>
|
||||
): Record<string, string> {
|
||||
const merged: Record<string, string> = {};
|
||||
for (const headers of headerSources) {
|
||||
if (headers) {
|
||||
Object.assign(merged, headers);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function buildMantleAnthropicBaseOptions(
|
||||
model: Model,
|
||||
options: SimpleStreamOptions | undefined,
|
||||
apiKey: string,
|
||||
) {
|
||||
return {
|
||||
temperature: requiresDefaultSampling(model.id) ? undefined : options?.temperature,
|
||||
maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32_000),
|
||||
signal: options?.signal,
|
||||
apiKey,
|
||||
cacheRetention: options?.cacheRetention,
|
||||
sessionId: options?.sessionId,
|
||||
onPayload: options?.onPayload,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
metadata: options?.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function adjustMaxTokensForThinking(
|
||||
baseMaxTokens: number,
|
||||
modelMaxTokens: number,
|
||||
reasoningLevel: NonNullable<SimpleStreamOptions["reasoning"]>,
|
||||
customBudgets?: SimpleStreamOptions["thinkingBudgets"],
|
||||
): { maxTokens: number; thinkingBudget: number } {
|
||||
const defaultBudgets = {
|
||||
minimal: 1024,
|
||||
low: 2048,
|
||||
medium: 8192,
|
||||
high: 16384,
|
||||
xhigh: 16384,
|
||||
max: 16384,
|
||||
} as const;
|
||||
const budgets = { ...defaultBudgets, ...customBudgets };
|
||||
const minOutputTokens = 1024;
|
||||
let thinkingBudget = budgets[reasoningLevel];
|
||||
const maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens);
|
||||
if (maxTokens <= thinkingBudget) {
|
||||
thinkingBudget = Math.max(0, maxTokens - minOutputTokens);
|
||||
}
|
||||
return { maxTokens, thinkingBudget };
|
||||
}
|
||||
|
||||
/** Create the Mantle Anthropic Messages stream function. */
|
||||
export function createMantleAnthropicStreamFn(deps?: {
|
||||
createClient?: (options: AnthropicOptions) => Anthropic;
|
||||
stream?: MantleAnthropicStream;
|
||||
}): StreamFn {
|
||||
return (model, context, options) => {
|
||||
const apiKey = options?.apiKey ?? "";
|
||||
const createClient = deps?.createClient ?? ((clientOptions) => new Anthropic(clientOptions));
|
||||
const streamFn = deps?.stream ?? stream;
|
||||
const client = createClient({
|
||||
apiKey: null,
|
||||
authToken: apiKey,
|
||||
baseURL: resolveMantleAnthropicBaseUrl(model.baseUrl),
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: mergeHeaders(
|
||||
{
|
||||
accept: "application/json",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"anthropic-beta": MANTLE_ANTHROPIC_BETA,
|
||||
},
|
||||
model.headers,
|
||||
options?.headers,
|
||||
),
|
||||
});
|
||||
const base = buildMantleAnthropicBaseOptions(model, options, apiKey);
|
||||
// Plugin package deps can give this plugin a distinct physical SDK copy.
|
||||
// The client API is the same, but the SDK class private field makes types nominal.
|
||||
const streamClient = client as unknown as Anthropic;
|
||||
const reasoning = resolveMantleReasoning(model, options);
|
||||
if (!reasoning) {
|
||||
return streamFn(model as Model<"anthropic-messages">, context, {
|
||||
...base,
|
||||
client: streamClient,
|
||||
thinkingEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
const adjusted = adjustMaxTokensForThinking(
|
||||
base.maxTokens || 0,
|
||||
model.maxTokens,
|
||||
reasoning,
|
||||
options?.thinkingBudgets,
|
||||
);
|
||||
return streamFn(model as Model<"anthropic-messages">, context, {
|
||||
...base,
|
||||
client: streamClient,
|
||||
maxTokens: adjusted.maxTokens,
|
||||
thinkingEnabled: true,
|
||||
...(isClaudeMythosPreviewModel(model) ? { effort: reasoning } : {}),
|
||||
thinkingBudgetTokens: adjusted.thinkingBudget,
|
||||
});
|
||||
};
|
||||
}
|
||||
594
extensions/amazon-bedrock-mantle/npm-shrinkwrap.json
generated
Normal file
594
extensions/amazon-bedrock-mantle/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,594 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.109.1",
|
||||
"@aws/bedrock-token-generator": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.109.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.109.1.tgz",
|
||||
"integrity": "sha512-q9OnEKLr5H9nxSuXdgDgJhxfYMiE+AaUEBze2Gk91UcaaLnsN+Lx5fbCYywiqurU/APLdwv23x03Wm6WN3EBsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-cognito-identity": {
|
||||
"version": "3.1078.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1078.0.tgz",
|
||||
"integrity": "sha512-BYy0X/+GMXlitKShxkdTsCexWwDrn8usY2Y2Z06M5MSi4aRT3Ce5ilyA6OubQUqOWfsmDMYrm8oBNaTIcQFyrg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.61",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/node-http-handler": "^4.9.2",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.974.27",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.27.tgz",
|
||||
"integrity": "sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@aws-sdk/xml-builder": "^3.972.33",
|
||||
"@aws/lambda-invoke-store": "^0.3.0",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/signature-v4": "^5.6.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"bowser": "^2.11.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-cognito-identity": {
|
||||
"version": "3.972.51",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.51.tgz",
|
||||
"integrity": "sha512-nXzAwRz0NOiHlG/HHea7oJ2ew2m21XZUU6h2cZMCrlNQqcWjMHCkun4D6E7CWqOxiFG0MeN8Gg5Iakrjv/UXrQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.52",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.52.tgz",
|
||||
"integrity": "sha512-sxuaHZGHqOgKB8OdL3doXa1NJjqmO60FPfyTnYVKGjX9taRsIEGS9pd+2yALmo06hijZ8L94uSK0kfXZsRmVyA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.54",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.54.tgz",
|
||||
"integrity": "sha512-e6yz52nq3SpR1oPLcvfsDM7H7k2gIYk/NSn/rwsFqzGXEwr3g0mRMlPbLaKCPCGNZJMU/gZg6/64B3eSam+gBw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/node-http-handler": "^4.9.2",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.972.59",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.59.tgz",
|
||||
"integrity": "sha512-9Um/UpruN76AdpiLnvwChVkJJwJ9Vx9ykk/2AeLxxSCM/YYRD8Kkq2towUk9fZQLV7dd9ATlsi87U7hKs0z/iQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.52",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.54",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.58",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.52",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.58",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.58",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/credential-provider-imds": "^4.4.5",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.58",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.58.tgz",
|
||||
"integrity": "sha512-H3q96qF8/DJsPsXMVtMRqSWOc85K5O4zos32untdw+vE5vw0f3a6qJo1YqbND4BsEIKd4iZmzzVUq9kV4LjbHg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.61",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.61.tgz",
|
||||
"integrity": "sha512-2U2KHMRCt1dlZoLU3KZR5g5EL4b0h2HHw96SkaUBK7qvEXPZj5rGRO/3ZTeJmh37dIYQuCnA2273rZOQvmsiHw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.52",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.54",
|
||||
"@aws-sdk/credential-provider-ini": "^3.972.59",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.52",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.58",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.58",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/credential-provider-imds": "^4.4.5",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.52",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.52.tgz",
|
||||
"integrity": "sha512-Aff9Ebs42lz+Ep1wkS+Nlwh5S0eahakpyskPsuKGjiBJ6ExOjNtxbfKJTKovQtQNgJ7oG1BH6esJwGrbs7qgSA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.972.58",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.58.tgz",
|
||||
"integrity": "sha512-syloC58mXOacUqM2toPNfwd7X3jT+tWj0F/cN7qdW1FQyI0q41J0tPf6DIZ56BF0x82iS9j3ALP45MoBz79YuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/token-providers": "3.1078.0",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.58",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.58.tgz",
|
||||
"integrity": "sha512-pTBImKzcGK+pcMKjL0fAJbnYzzYd1c0UDc7BSIOGNQhF9Nuk66vWlIXfYTYyzNSs+w8Q/vfbbNDDU8zdrouwLg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-providers": {
|
||||
"version": "3.1078.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1078.0.tgz",
|
||||
"integrity": "sha512-V9Tr3MrNWUfTGgTMIr+WJaMC/VDbXY57BzrGDuyDZn7+vgZjAEG6nI5nMdfnTGdvV9wq1n0zZPYW2RDfcsWNCw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-cognito-identity": "3.1078.0",
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/credential-provider-cognito-identity": "^3.972.51",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.52",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.54",
|
||||
"@aws-sdk/credential-provider-ini": "^3.972.59",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.58",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.61",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.52",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.58",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.58",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/credential-provider-imds": "^4.4.5",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.26",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.26.tgz",
|
||||
"integrity": "sha512-Lwe3F6K7bs+jEubp1LbrvzeMBYb5fMazJ1IxV9TtKWPF8CSh67Fmwyq9fLz3NL/k55Dfpuph5Dimw76JFgr+SA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/node-http-handler": "^4.9.2",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.38",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz",
|
||||
"integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/signature-v4": "^5.6.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1078.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz",
|
||||
"integrity": "sha512-/uyXLBGu3Lw1GbBA2X66hcOMnKtMcqAIF+3/eHfxBQmUeXF2sdqozDPrTfEr/TnSd0D6deZar+eVyhEqqWu29w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.973.15",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz",
|
||||
"integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/util-format-url": {
|
||||
"version": "3.972.28",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.28.tgz",
|
||||
"integrity": "sha512-nPBeLFpFaLepgKP8e87fVrDrEKV6AgYEcjRwsQyxcfw2RguMyZSeR31kM7AcCvQpJO/iK+JFzNAmN50vmcUeWg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.33",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz",
|
||||
"integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/bedrock-token-generator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws/bedrock-token-generator/-/bedrock-token-generator-1.1.0.tgz",
|
||||
"integrity": "sha512-i+DkWnfdA4j4sffy9dI4k3OGoOWqN8CTGdtO4IZ3c0kpKYFr6KyqzqLQmoRNrF3ACFcWj6u+J6cbBQ97j9wx5w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "^3.525.0",
|
||||
"@aws-sdk/util-format-url": ">=3.525.0",
|
||||
"@smithy/config-resolver": "^4.1.4",
|
||||
"@smithy/hash-node": ">=2.1.3",
|
||||
"@smithy/invalid-dependency": "^4.0.4",
|
||||
"@smithy/node-config-provider": "^4.1.3",
|
||||
"@smithy/protocol-http": ">=3.2.1",
|
||||
"@smithy/signature-v4": ">=2.1.3",
|
||||
"@smithy/types": ">=2.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/config-resolver": {
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.6.5.tgz",
|
||||
"integrity": "sha512-EWaWeWXmEa2BMk6x0I++Nec4lMmTgzBI48ri7Ct6vv3YdcTzM3JrHMC3R3JkUVohMxA5D81WDhcCP+fs6kv4AQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.0.tgz",
|
||||
"integrity": "sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/credential-provider-imds": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.5.tgz",
|
||||
"integrity": "sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/fetch-http-handler": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.2.tgz",
|
||||
"integrity": "sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/hash-node": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.4.5.tgz",
|
||||
"integrity": "sha512-2pyNsNH0rQJLTdIYDpona33axUSwhTPjJ5wl4twukJpEwgvltNL/mqZ7Tcgohz4DB8rhFwRPDd/3BlYuIvMYvw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/invalid-dependency": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.4.5.tgz",
|
||||
"integrity": "sha512-CzXd06th+MmAoq7dfmP2Olg4ZIpnkcJnnx20Kgusc6dBC0we6+nyaJEdY78aGiAX95Oss4x4FRS6GlXttVzN6w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-config-provider": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.5.5.tgz",
|
||||
"integrity": "sha512-oYaPOb+00xXWDUb5t05b0trcnZ4XSr/cDKioiZSwSKk8crvnu8AlElTddpq6s9HT4lELJuuduCAsbco8AjZUOQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.2.tgz",
|
||||
"integrity": "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/protocol-http": {
|
||||
"version": "5.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.5.5.tgz",
|
||||
"integrity": "sha512-3qVnJJQN0P5tHAui6Pusz958lyXLgUezbh3wiDL6xqMY90TvSB7knLIDNr2xPCF2E5TsUfYt5aRUS3466RFkuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/signature-v4": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.1.tgz",
|
||||
"integrity": "sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz",
|
||||
"integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
extensions/amazon-bedrock-mantle/openclaw.plugin.json
Normal file
36
extensions/amazon-bedrock-mantle/openclaw.plugin.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "amazon-bedrock-mantle",
|
||||
"name": "Amazon Bedrock Mantle",
|
||||
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"discovery": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "When false, skip implicit Mantle model discovery."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"discovery": {
|
||||
"label": "Model Discovery",
|
||||
"help": "Plugin-owned controls for Amazon Bedrock Mantle model auto-discovery."
|
||||
},
|
||||
"discovery.enabled": {
|
||||
"label": "Enable Discovery",
|
||||
"help": "When false, OpenClaw keeps the Amazon Bedrock Mantle plugin available but skips implicit startup discovery. Leave unset for default auto-detect behavior."
|
||||
}
|
||||
},
|
||||
"providers": ["amazon-bedrock-mantle"]
|
||||
}
|
||||
38
extensions/amazon-bedrock-mantle/package.json
Normal file
38
extensions/amazon-bedrock-mantle/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.109.1",
|
||||
"@aws/bedrock-token-generator": "1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
82
extensions/amazon-bedrock-mantle/register.sync.runtime.ts
Normal file
82
extensions/amazon-bedrock-mantle/register.sync.runtime.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Synchronous Amazon Bedrock Mantle provider registration. It wires discovery,
|
||||
* runtime bearer-token preparation, stream wrappers, and failover classifiers.
|
||||
*/
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
mergeImplicitMantleProvider,
|
||||
resolveImplicitMantleProvider,
|
||||
resolveMantleBearerToken,
|
||||
resolveMantleRuntimeBearerToken,
|
||||
} from "./discovery.js";
|
||||
import { createMantleAnthropicStreamFn } from "./mantle-anthropic.runtime.js";
|
||||
|
||||
type BedrockMantlePluginConfig = {
|
||||
discovery?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/** Register the Amazon Bedrock Mantle provider with OpenClaw. */
|
||||
export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
|
||||
const providerId = "amazon-bedrock-mantle";
|
||||
const startupPluginConfig = (api.pluginConfig ?? {}) as BedrockMantlePluginConfig;
|
||||
|
||||
function resolveCurrentPluginConfig(
|
||||
config: OpenClawConfig | undefined,
|
||||
): BedrockMantlePluginConfig | undefined {
|
||||
const runtimePluginConfig = resolvePluginConfigObject(config, providerId);
|
||||
return (
|
||||
(runtimePluginConfig as BedrockMantlePluginConfig | undefined) ??
|
||||
(config ? undefined : startupPluginConfig)
|
||||
);
|
||||
}
|
||||
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
label: "Amazon Bedrock Mantle (OpenAI-compatible)",
|
||||
docsPath: "/providers/bedrock-mantle",
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const currentPluginConfig = resolveCurrentPluginConfig(ctx.config);
|
||||
const implicit = await resolveImplicitMantleProvider({
|
||||
env: ctx.env,
|
||||
pluginConfig: currentPluginConfig,
|
||||
});
|
||||
if (!implicit) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: mergeImplicitMantleProvider({
|
||||
existing: ctx.config.models?.providers?.[providerId],
|
||||
implicit,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveConfigApiKey: ({ env }) =>
|
||||
resolveMantleBearerToken(env) ? "env:AWS_BEARER_TOKEN_BEDROCK" : undefined,
|
||||
prepareRuntimeAuth: async ({ apiKey, env }) =>
|
||||
await resolveMantleRuntimeBearerToken({
|
||||
apiKey,
|
||||
env,
|
||||
}),
|
||||
createStreamFn: ({ model }) =>
|
||||
model.api === "anthropic-messages" ? createMantleAnthropicStreamFn() : undefined,
|
||||
matchesContextOverflowError: ({ errorMessage }) =>
|
||||
/context_length_exceeded|max.*tokens.*exceeded/i.test(errorMessage),
|
||||
classifyFailoverReason: ({ errorMessage }) => {
|
||||
if (/rate_limit|too many requests|429/i.test(errorMessage)) {
|
||||
return "rate_limit";
|
||||
}
|
||||
if (/overloaded|503|service.*unavailable/i.test(errorMessage)) {
|
||||
return "overloaded";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
16
extensions/amazon-bedrock-mantle/tsconfig.json
Normal file
16
extensions/amazon-bedrock-mantle/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
11
extensions/amazon-bedrock/README.md
Normal file
11
extensions/amazon-bedrock/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OpenClaw Amazon Bedrock Provider
|
||||
|
||||
Official OpenClaw provider plugin for Amazon Bedrock. It adds Bedrock model discovery, text generation, embeddings, and guardrail-aware provider routing for agents that use AWS-hosted models.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugin add @openclaw/amazon-bedrock-provider
|
||||
```
|
||||
|
||||
Configure AWS credentials and region through your normal OpenClaw credential/profile setup, then select Bedrock models with the `amazon-bedrock/...` provider prefix.
|
||||
10
extensions/amazon-bedrock/api.ts
Normal file
10
extensions/amazon-bedrock/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Lightweight Amazon Bedrock API barrel for config and discovery consumers.
|
||||
* Keep runtime streaming exports out of this path so metadata flows stay cheap.
|
||||
*/
|
||||
export { mergeImplicitBedrockProvider, resolveBedrockConfigApiKey } from "./discovery-shared.js";
|
||||
export {
|
||||
discoverBedrockModels,
|
||||
resetBedrockDiscoveryCacheForTest,
|
||||
resolveImplicitBedrockProvider,
|
||||
} from "./discovery.js";
|
||||
34
extensions/amazon-bedrock/aws-credential-refresh.ts
Normal file
34
extensions/amazon-bedrock/aws-credential-refresh.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* AWS shared config cache refresh helpers for Bedrock. They nudge the AWS SDK
|
||||
* to re-read profile/SSO config when no static credentials are present.
|
||||
*/
|
||||
type SharedIniFileLoader = {
|
||||
loadSharedConfigFiles(init?: { ignoreCache?: boolean }): Promise<unknown>;
|
||||
};
|
||||
|
||||
function hasStaticAwsCredentialEnv(env: NodeJS.ProcessEnv): boolean {
|
||||
return Boolean(env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY);
|
||||
}
|
||||
|
||||
/** Return whether Bedrock should refresh the AWS shared config cache before discovery. */
|
||||
export function shouldRefreshAwsSharedConfigCacheForBedrock(env: NodeJS.ProcessEnv): boolean {
|
||||
if (env.AWS_BEDROCK_SKIP_AUTH === "1" || env.AWS_BEARER_TOKEN_BEDROCK) {
|
||||
return false;
|
||||
}
|
||||
return !hasStaticAwsCredentialEnv(env);
|
||||
}
|
||||
|
||||
async function loadSharedIniFileLoader(): Promise<SharedIniFileLoader> {
|
||||
return (await import("@smithy/shared-ini-file-loader")) as SharedIniFileLoader;
|
||||
}
|
||||
|
||||
/** Refresh Smithy shared config files when Bedrock needs default-chain credentials. */
|
||||
export async function refreshAwsSharedConfigCacheForBedrock(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<void> {
|
||||
if (!shouldRefreshAwsSharedConfigCacheForBedrock(env)) {
|
||||
return;
|
||||
}
|
||||
const loader = await loadSharedIniFileLoader();
|
||||
await loader.loadSharedConfigFiles({ ignoreCache: true });
|
||||
}
|
||||
54
extensions/amazon-bedrock/bedrock-options.ts
Normal file
54
extensions/amazon-bedrock/bedrock-options.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Stream option extensions and prompt-cache policy for Amazon Bedrock models.
|
||||
* Provider registration and runtime streaming share these contracts.
|
||||
*/
|
||||
import type { StreamOptions, ThinkingBudgets, ThinkingLevel } from "openclaw/plugin-sdk/llm";
|
||||
|
||||
/** How Bedrock thinking output should be displayed to users. */
|
||||
export type BedrockThinkingDisplay = "summarized" | "omitted";
|
||||
|
||||
/** Extra Bedrock-specific stream options accepted by the provider runtime. */
|
||||
export interface BedrockOptions extends StreamOptions {
|
||||
region?: string;
|
||||
profile?: string;
|
||||
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
|
||||
reasoning?: ThinkingLevel;
|
||||
thinkingBudgets?: ThinkingBudgets;
|
||||
interleavedThinking?: boolean;
|
||||
thinkingDisplay?: BedrockThinkingDisplay;
|
||||
requestMetadata?: Record<string, string>;
|
||||
bearerToken?: string;
|
||||
}
|
||||
|
||||
function getModelMatchCandidates(modelId: string, modelName?: string): string[] {
|
||||
const values = modelName ? [modelId, modelName] : [modelId];
|
||||
return values.flatMap((value) => {
|
||||
const lower = value.toLowerCase();
|
||||
return [lower, lower.replace(/[\s_.:]+/g, "-")];
|
||||
});
|
||||
}
|
||||
|
||||
/** Return whether a Bedrock model is known to support Anthropic prompt caching. */
|
||||
export function supportsBedrockPromptCaching(modelId: string, modelName?: string): boolean {
|
||||
const candidates = getModelMatchCandidates(modelId, modelName);
|
||||
const hasClaudeRef = candidates.some((s) => s.includes("claude"));
|
||||
if (!hasClaudeRef) {
|
||||
if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (candidates.some((s) => s.includes("-4-"))) {
|
||||
return true;
|
||||
}
|
||||
if (candidates.some((s) => s.includes("claude-fable-5"))) {
|
||||
return true;
|
||||
}
|
||||
if (candidates.some((s) => s.includes("claude-3-7-sonnet"))) {
|
||||
return true;
|
||||
}
|
||||
if (candidates.some((s) => s.includes("claude-3-5-haiku"))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
5
extensions/amazon-bedrock/config-api.ts
Normal file
5
extensions/amazon-bedrock/config-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Narrow config compatibility barrel for Amazon Bedrock. Doctor/config code can
|
||||
* import this without loading runtime provider dependencies.
|
||||
*/
|
||||
export { migrateAmazonBedrockLegacyConfig } from "./config-compat.js";
|
||||
82
extensions/amazon-bedrock/config-compat.test.ts
Normal file
82
extensions/amazon-bedrock/config-compat.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Amazon Bedrock tests cover config compat plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migrateAmazonBedrockLegacyConfig } from "./config-compat.js";
|
||||
|
||||
describe("amazon-bedrock config migration", () => {
|
||||
it("moves legacy models.bedrockDiscovery into plugin-owned discovery config", () => {
|
||||
const result = migrateAmazonBedrockLegacyConfig({
|
||||
models: {
|
||||
mode: "merge",
|
||||
bedrockDiscovery: {
|
||||
enabled: true,
|
||||
region: "us-east-1",
|
||||
refreshInterval: 3600,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config).toEqual({
|
||||
models: {
|
||||
mode: "merge",
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"amazon-bedrock": {
|
||||
config: {
|
||||
discovery: {
|
||||
enabled: true,
|
||||
region: "us-east-1",
|
||||
refreshInterval: 3600,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
"Moved models.bedrockDiscovery → plugins.entries.amazon-bedrock.config.discovery.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges missing fields into existing plugin discovery config", () => {
|
||||
const result = migrateAmazonBedrockLegacyConfig({
|
||||
models: {
|
||||
bedrockDiscovery: {
|
||||
enabled: true,
|
||||
region: "us-east-1",
|
||||
providerFilter: ["anthropic"],
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"amazon-bedrock": {
|
||||
config: {
|
||||
discovery: {
|
||||
region: "us-west-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config).toEqual({
|
||||
plugins: {
|
||||
entries: {
|
||||
"amazon-bedrock": {
|
||||
config: {
|
||||
discovery: {
|
||||
enabled: true,
|
||||
region: "us-west-2",
|
||||
providerFilter: ["anthropic"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
"Merged models.bedrockDiscovery → plugins.entries.amazon-bedrock.config.discovery (filled missing fields from legacy; kept explicit plugin config values).",
|
||||
]);
|
||||
});
|
||||
});
|
||||
112
extensions/amazon-bedrock/config-compat.ts
Normal file
112
extensions/amazon-bedrock/config-compat.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Legacy config migration for Amazon Bedrock discovery settings. It moves
|
||||
* old `models.bedrockDiscovery` config into plugin-local config shape.
|
||||
*/
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const LEGACY_PATH = "models.bedrockDiscovery";
|
||||
const TARGET_PATH = "plugins.entries.amazon-bedrock.config.discovery";
|
||||
const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
function isBlockedObjectKey(key: string): boolean {
|
||||
return BLOCKED_OBJECT_KEYS.has(key);
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): JsonRecord | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function ensureRecord(root: JsonRecord, key: string): JsonRecord {
|
||||
const existing = root[key];
|
||||
if (isRecord(existing)) {
|
||||
return existing;
|
||||
}
|
||||
const next: JsonRecord = {};
|
||||
root[key] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeMissing(target: JsonRecord, source: JsonRecord): void {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined || isBlockedObjectKey(key)) {
|
||||
continue;
|
||||
}
|
||||
const existing = target[key];
|
||||
if (existing === undefined) {
|
||||
target[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (isRecord(existing) && isRecord(value)) {
|
||||
mergeMissing(existing, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cloneRecord<T extends JsonRecord>(value: T | undefined): T {
|
||||
return { ...value } as T;
|
||||
}
|
||||
|
||||
function resolveLegacyBedrockDiscoveryConfig(raw: unknown): JsonRecord | undefined {
|
||||
if (!isRecord(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const models = getRecord(raw.models);
|
||||
return getRecord(models?.bedrockDiscovery) ?? undefined;
|
||||
}
|
||||
|
||||
function pruneEmptyModelsRoot(root: JsonRecord): void {
|
||||
const models = getRecord(root.models);
|
||||
if (models && Object.keys(models).length === 0) {
|
||||
delete root.models;
|
||||
}
|
||||
}
|
||||
|
||||
/** Migrate legacy Bedrock discovery config into `plugins.entries.amazon-bedrock.config`. */
|
||||
export function migrateAmazonBedrockLegacyConfig<T>(raw: T): { config: T; changes: string[] } {
|
||||
if (!isRecord(raw)) {
|
||||
return { config: raw, changes: [] };
|
||||
}
|
||||
|
||||
const legacy = resolveLegacyBedrockDiscoveryConfig(raw);
|
||||
if (!legacy) {
|
||||
return { config: raw, changes: [] };
|
||||
}
|
||||
|
||||
const nextRoot = structuredClone(raw) as JsonRecord;
|
||||
const models = ensureRecord(nextRoot, "models");
|
||||
delete models.bedrockDiscovery;
|
||||
pruneEmptyModelsRoot(nextRoot);
|
||||
|
||||
const changes: string[] = [];
|
||||
if (Object.keys(legacy).length === 0) {
|
||||
changes.push(`Removed empty ${LEGACY_PATH}.`);
|
||||
return { config: nextRoot as T, changes };
|
||||
}
|
||||
|
||||
const plugins = ensureRecord(nextRoot, "plugins");
|
||||
const entries = ensureRecord(plugins, "entries");
|
||||
const entry = ensureRecord(entries, "amazon-bedrock");
|
||||
const config = ensureRecord(entry, "config");
|
||||
const existing = getRecord(config.discovery) ?? undefined;
|
||||
|
||||
if (!existing) {
|
||||
config.discovery = cloneRecord(legacy);
|
||||
changes.push(`Moved ${LEGACY_PATH} → ${TARGET_PATH}.`);
|
||||
return { config: nextRoot as T, changes };
|
||||
}
|
||||
|
||||
const merged = cloneRecord(existing);
|
||||
mergeMissing(merged, legacy);
|
||||
config.discovery = merged;
|
||||
if (JSON.stringify(merged) !== JSON.stringify(existing)) {
|
||||
changes.push(
|
||||
`Merged ${LEGACY_PATH} → ${TARGET_PATH} (filled missing fields from legacy; kept explicit plugin config values).`,
|
||||
);
|
||||
return { config: nextRoot as T, changes };
|
||||
}
|
||||
|
||||
changes.push(`Removed ${LEGACY_PATH} (${TARGET_PATH} already set).`);
|
||||
return { config: nextRoot as T, changes };
|
||||
}
|
||||
34
extensions/amazon-bedrock/discovery-shared.ts
Normal file
34
extensions/amazon-bedrock/discovery-shared.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Shared Amazon Bedrock discovery helpers used by plugin runtime and config
|
||||
* consumers without pulling in the AWS discovery implementation.
|
||||
*/
|
||||
import { resolveAwsSdkEnvVarName } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
/** Resolve the config auth marker that tells OpenClaw to use AWS SDK credentials. */
|
||||
export function resolveBedrockConfigApiKey(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
// When no AWS auth env marker is present, Bedrock should fall back to the
|
||||
// AWS SDK default credential chain instead of persisting a fake apiKey marker.
|
||||
return resolveAwsSdkEnvVarName(env);
|
||||
}
|
||||
|
||||
/** Merge an implicit Bedrock provider catalog with any explicit user config. */
|
||||
export function mergeImplicitBedrockProvider(params: {
|
||||
existing: ModelProviderConfig | undefined;
|
||||
implicit: ModelProviderConfig;
|
||||
}): ModelProviderConfig {
|
||||
const { existing, implicit } = params;
|
||||
if (!existing) {
|
||||
return implicit;
|
||||
}
|
||||
return {
|
||||
...implicit,
|
||||
...existing,
|
||||
models:
|
||||
Array.isArray(existing.models) && existing.models.length > 0
|
||||
? existing.models
|
||||
: implicit.models,
|
||||
};
|
||||
}
|
||||
772
extensions/amazon-bedrock/discovery.test.ts
Normal file
772
extensions/amazon-bedrock/discovery.test.ts
Normal file
@@ -0,0 +1,772 @@
|
||||
// Amazon Bedrock tests cover discovery plugin behavior.
|
||||
import type { BedrockClient } from "@aws-sdk/client-bedrock";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
discoverBedrockModels,
|
||||
mergeImplicitBedrockProvider,
|
||||
resetBedrockDiscoveryCacheForTest,
|
||||
resolveBedrockConfigApiKey,
|
||||
resolveImplicitBedrockProvider,
|
||||
} from "./api.js";
|
||||
|
||||
const sendMock = vi.fn();
|
||||
const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient;
|
||||
|
||||
const baseActiveAnthropicSummary = {
|
||||
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
modelName: "Claude 3.7 Sonnet",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
};
|
||||
|
||||
function mockSingleActiveSummary(overrides: Partial<typeof baseActiveAnthropicSummary> = {}): void {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [{ ...baseActiveAnthropicSummary, ...overrides }],
|
||||
})
|
||||
// ListInferenceProfiles response (empty — no inference profiles in basic tests).
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
|
||||
}
|
||||
|
||||
function expectModelFields(model: unknown, expected: Record<string, unknown>): void {
|
||||
if (!model || typeof model !== "object") {
|
||||
throw new Error("Expected model record");
|
||||
}
|
||||
const actual = model as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(actual[key]).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
describe("bedrock discovery", () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockClear();
|
||||
resetBedrockDiscoveryCacheForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetBedrockDiscoveryCacheForTest();
|
||||
});
|
||||
|
||||
it("filters to active streaming text models and maps modalities", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
modelName: "Claude 3.7 Sonnet",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT", "IMAGE"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
{
|
||||
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
|
||||
modelName: "Claude 3 Haiku",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: false,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
{
|
||||
modelId: "meta.llama3-8b-instruct-v1:0",
|
||||
modelName: "Llama 3 8B",
|
||||
providerName: "meta",
|
||||
inputModalities: ["TEXT"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "INACTIVE" },
|
||||
},
|
||||
{
|
||||
modelId: "amazon.titan-embed-text-v1",
|
||||
modelName: "Titan Embed",
|
||||
providerName: "amazon",
|
||||
inputModalities: ["TEXT"],
|
||||
outputModalities: ["EMBEDDING"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
expect(models).toHaveLength(1);
|
||||
expectModelFields(models[0], {
|
||||
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
name: "Claude 3.7 Sonnet",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 200000,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies provider filter", async () => {
|
||||
mockSingleActiveSummary();
|
||||
|
||||
const models = await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { providerFilter: ["amazon"] },
|
||||
clientFactory,
|
||||
});
|
||||
expect(models).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses configured defaults for context and max tokens", async () => {
|
||||
mockSingleActiveSummary({
|
||||
modelId: "example.unknown-text-v1:0",
|
||||
modelName: "Example Unknown Text",
|
||||
providerName: "example",
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { defaultContextWindow: 64000, defaultMaxTokens: 8192 },
|
||||
clientFactory,
|
||||
});
|
||||
expectModelFields(models[0], { contextWindow: 64000, maxTokens: 8192 });
|
||||
});
|
||||
|
||||
it("keeps the conservative fallback for unknown inference profiles", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "jp.example.unknown-text-v1:0",
|
||||
inferenceProfileName: "JP Example Unknown Text",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn:
|
||||
"arn:aws:bedrock:ap-northeast-1::foundation-model/example.unknown-text-v1:0",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "ap-northeast-1", clientFactory });
|
||||
|
||||
expect(models).toHaveLength(1);
|
||||
expectModelFields(models[0], {
|
||||
id: "jp.example.unknown-text-v1:0",
|
||||
contextWindow: 32000,
|
||||
maxTokens: 4096,
|
||||
input: ["text"],
|
||||
});
|
||||
});
|
||||
|
||||
it("marks known Fable inference profile fallbacks as reasoning capable", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "us.anthropic.claude-fable-5",
|
||||
inferenceProfileName: "US Claude Fable 5",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-fable-5",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
|
||||
expect(models).toHaveLength(1);
|
||||
expectModelFields(models[0], {
|
||||
id: "us.anthropic.claude-fable-5",
|
||||
reasoning: true,
|
||||
contextWindow: 1_000_000,
|
||||
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips Mythos Preview inference profiles because Mantle owns that route", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "us.anthropic.claude-mythos-preview",
|
||||
inferenceProfileName: "US Claude Mythos Preview",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn:
|
||||
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-mythos-preview",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes region-prefixed versioned model ids when resolving context windows", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "jp.anthropic.claude-sonnet-4-6-v1:0",
|
||||
inferenceProfileName: "JP Claude Sonnet 4.6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn:
|
||||
"arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-sonnet-4-6-v1:0",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "ap-northeast-1", clientFactory });
|
||||
|
||||
expectModelFields(models[0], {
|
||||
id: "jp.anthropic.claude-sonnet-4-6-v1:0",
|
||||
contextWindow: 1_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses 1M context window for dotted Claude Opus 4.8 Bedrock refs", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-opus-4.8-v1:0",
|
||||
modelName: "Claude Opus 4.8",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "us.anthropic.claude-opus-4.8-v1:0",
|
||||
inferenceProfileName: "US Claude Opus 4.8",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn:
|
||||
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4.8-v1:0",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
|
||||
expectModelFields(
|
||||
models.find((model) => model.id === "anthropic.claude-opus-4.8-v1:0"),
|
||||
{
|
||||
contextWindow: 1_000_000,
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
||||
},
|
||||
);
|
||||
expectModelFields(
|
||||
models.find((model) => model.id === "us.anthropic.claude-opus-4.8-v1:0"),
|
||||
{
|
||||
contextWindow: 1_000_000,
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("applies Fable limits and reasoning metadata to foundation and profile models", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-fable-5",
|
||||
modelName: "Claude Fable 5",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT", "IMAGE"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "company-fable",
|
||||
inferenceProfileName: "Company Fable",
|
||||
status: "ACTIVE",
|
||||
type: "APPLICATION",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-fable-5",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
const expected = {
|
||||
reasoning: true,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
|
||||
};
|
||||
|
||||
expectModelFields(
|
||||
models.find((model) => model.id === "anthropic.claude-fable-5"),
|
||||
expected,
|
||||
);
|
||||
expectModelFields(
|
||||
models.find((model) => model.id === "company-fable"),
|
||||
{
|
||||
...expected,
|
||||
params: { canonicalModelId: "claude-fable-5" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("caches results when refreshInterval is enabled", async () => {
|
||||
mockSingleActiveSummary();
|
||||
|
||||
await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
// 2 calls on first discovery (ListFoundationModels + ListInferenceProfiles), 0 on cached second.
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips cache when refreshInterval expiry overflows", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] })
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
|
||||
|
||||
await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { refreshInterval: 1 },
|
||||
now: () => 8_640_000_000_000_000,
|
||||
clientFactory,
|
||||
});
|
||||
await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { refreshInterval: 1 },
|
||||
now: () => 8_640_000_000_000_000,
|
||||
clientFactory,
|
||||
});
|
||||
expect(sendMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("skips cache when refreshInterval is 0", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] })
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
|
||||
|
||||
await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { refreshInterval: 0 },
|
||||
clientFactory,
|
||||
});
|
||||
await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { refreshInterval: 0 },
|
||||
clientFactory,
|
||||
});
|
||||
// 2 calls per discovery (ListFoundationModels + ListInferenceProfiles) × 2 runs.
|
||||
expect(sendMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("resolves the Bedrock config apiKey from AWS auth env vars", () => {
|
||||
expect(
|
||||
resolveBedrockConfigApiKey({
|
||||
AWS_BEARER_TOKEN_BEDROCK: "bearer", // pragma: allowlist secret
|
||||
AWS_PROFILE: "default",
|
||||
}),
|
||||
).toBe("AWS_BEARER_TOKEN_BEDROCK");
|
||||
|
||||
// When no AWS env vars are present (e.g. instance role), no marker should be injected.
|
||||
// The aws-sdk credential chain handles auth at request time. (#49891)
|
||||
expect(resolveBedrockConfigApiKey({} as NodeJS.ProcessEnv)).toBeUndefined();
|
||||
|
||||
// When AWS_PROFILE is explicitly set, it should return the marker.
|
||||
expect(resolveBedrockConfigApiKey({ AWS_PROFILE: "default" } as NodeJS.ProcessEnv)).toBe(
|
||||
"AWS_PROFILE",
|
||||
);
|
||||
});
|
||||
|
||||
it("discovers inference profiles and inherits foundation model capabilities", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-sonnet-4-6",
|
||||
modelName: "Claude Sonnet 4.6",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT", "IMAGE"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "us.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "US Anthropic Claude Sonnet 4.6",
|
||||
inferenceProfileArn:
|
||||
"arn:aws:bedrock:us-east-1::inference-profile/us.anthropic.claude-sonnet-4-6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
inferenceProfileId: "eu.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "EU Anthropic Claude Sonnet 4.6",
|
||||
inferenceProfileArn:
|
||||
"arn:aws:bedrock:eu-west-1::inference-profile/eu.anthropic.claude-sonnet-4-6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:eu-west-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
inferenceProfileId: "global.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "Global Anthropic Claude Sonnet 4.6",
|
||||
inferenceProfileArn:
|
||||
"arn:aws:bedrock:us-east-1::inference-profile/global.anthropic.claude-sonnet-4-6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Inactive profile should be filtered out.
|
||||
{
|
||||
inferenceProfileId: "ap.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "AP Claude Sonnet 4.6",
|
||||
status: "LEGACY",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
|
||||
// Foundation model + 3 active inference profiles = 4 models.
|
||||
expect(models).toHaveLength(4);
|
||||
|
||||
// Global profiles should be sorted first (recommended for most users).
|
||||
expect(models[0]?.id).toBe("global.anthropic.claude-sonnet-4-6");
|
||||
|
||||
const foundationModel = models.find((m) => m.id === "anthropic.claude-sonnet-4-6");
|
||||
const usProfile = models.find((m) => m.id === "us.anthropic.claude-sonnet-4-6");
|
||||
const euProfile = models.find((m) => m.id === "eu.anthropic.claude-sonnet-4-6");
|
||||
const globalProfile = models.find((m) => m.id === "global.anthropic.claude-sonnet-4-6");
|
||||
|
||||
// Foundation model has image input.
|
||||
expectModelFields(foundationModel, { input: ["text", "image"] });
|
||||
|
||||
// Inference profiles inherit image input from the foundation model.
|
||||
expectModelFields(usProfile, {
|
||||
name: "US Anthropic Claude Sonnet 4.6",
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 4096,
|
||||
params: { canonicalModelId: "claude-sonnet-4-6" },
|
||||
});
|
||||
expect(usProfile?.thinkingLevelMap).toBeUndefined();
|
||||
expectModelFields(euProfile, { input: ["text", "image"] });
|
||||
expectModelFields(globalProfile, { input: ["text", "image"] });
|
||||
|
||||
// Inactive profile should not be present.
|
||||
expect(models.find((m) => m.id === "ap.anthropic.claude-sonnet-4-6")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gracefully handles ListInferenceProfiles permission errors", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [baseActiveAnthropicSummary],
|
||||
})
|
||||
// Simulate AccessDeniedException for ListInferenceProfiles.
|
||||
.mockRejectedValueOnce(new Error("AccessDeniedException"));
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
// Foundation model should still be discovered despite profile discovery failure.
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0]?.id).toBe("anthropic.claude-3-7-sonnet-20250219-v1:0");
|
||||
});
|
||||
|
||||
it("keeps matching inference profiles when provider filters are enabled", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-sonnet-4-6",
|
||||
modelName: "Claude Sonnet 4.6",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT", "IMAGE"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "global.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "Global Anthropic Claude Sonnet 4.6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { providerFilter: ["anthropic"] },
|
||||
clientFactory,
|
||||
});
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual([
|
||||
"global.anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers backing model ARNs for application profiles with region-like ids", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-sonnet-4-6",
|
||||
modelName: "Claude Sonnet 4.6",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT", "IMAGE"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "us.my-prod-profile",
|
||||
inferenceProfileName: "Prod Claude Profile",
|
||||
status: "ACTIVE",
|
||||
type: "APPLICATION",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
const profile = models.find((model) => model.id === "us.my-prod-profile");
|
||||
|
||||
expectModelFields(profile, {
|
||||
id: "us.my-prod-profile",
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the resolved base model id for application-profile context fallback", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "us.my-prod-profile",
|
||||
inferenceProfileName: "Prod Claude Profile",
|
||||
status: "ACTIVE",
|
||||
type: "APPLICATION",
|
||||
models: [
|
||||
{
|
||||
modelArn:
|
||||
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1:0",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
|
||||
expectModelFields(models[0], {
|
||||
id: "us.my-prod-profile",
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 4096,
|
||||
input: ["text"],
|
||||
params: { canonicalModelId: "claude-opus-4-6-v1:0" },
|
||||
thinkingLevelMap: { xhigh: null, max: "max" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges implicit Bedrock models into explicit provider overrides", () => {
|
||||
expect(
|
||||
mergeImplicitBedrockProvider({
|
||||
existing: {
|
||||
baseUrl: "https://override.example.com",
|
||||
headers: { "x-test-header": "1" },
|
||||
models: [],
|
||||
},
|
||||
implicit: {
|
||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
api: "bedrock-converse-stream",
|
||||
auth: "aws-sdk",
|
||||
models: [
|
||||
{
|
||||
id: "amazon.nova-micro-v1:0",
|
||||
name: "Nova",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1,
|
||||
maxTokens: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}).models?.map((model) => model.id),
|
||||
).toEqual(["amazon.nova-micro-v1:0"]);
|
||||
});
|
||||
|
||||
it("uses plugin-owned discovery config without runtime legacy fallback", async () => {
|
||||
mockSingleActiveSummary();
|
||||
|
||||
const pluginEnabled = await resolveImplicitBedrockProvider({
|
||||
pluginConfig: {
|
||||
discovery: {
|
||||
enabled: true,
|
||||
region: "us-east-1",
|
||||
},
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
clientFactory,
|
||||
});
|
||||
|
||||
expect(pluginEnabled?.baseUrl).toBe("https://bedrock-runtime.us-east-1.amazonaws.com");
|
||||
// 2 calls per discovery (ListFoundationModels + ListInferenceProfiles).
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Ported from #65449 by @alickgithub2 — extended to also cover apac. prefix
|
||||
it("resolves au. and apac. prefixes for regional inference profiles", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-sonnet-4-6",
|
||||
modelName: "Claude Sonnet 4.6",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT", "IMAGE"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "au.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "AU Anthropic Claude Sonnet 4.6",
|
||||
inferenceProfileArn:
|
||||
"arn:aws:bedrock:ap-southeast-2::inference-profile/au.anthropic.claude-sonnet-4-6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [], // no ARNs — forces the prefix-regex fallback
|
||||
},
|
||||
{
|
||||
inferenceProfileId: "apac.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "APAC Anthropic Claude Sonnet 4.6",
|
||||
inferenceProfileArn:
|
||||
"arn:aws:bedrock:ap-northeast-1::inference-profile/apac.anthropic.claude-sonnet-4-6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "ap-southeast-2", clientFactory });
|
||||
|
||||
// Foundation model + 2 regional inference profiles
|
||||
expect(models).toHaveLength(3);
|
||||
|
||||
const auProfile = models.find((m) => m.id === "au.anthropic.claude-sonnet-4-6");
|
||||
expectModelFields(auProfile, {
|
||||
id: "au.anthropic.claude-sonnet-4-6",
|
||||
name: "AU Anthropic Claude Sonnet 4.6",
|
||||
input: ["text", "image"],
|
||||
});
|
||||
|
||||
const apacProfile = models.find((m) => m.id === "apac.anthropic.claude-sonnet-4-6");
|
||||
expectModelFields(apacProfile, {
|
||||
id: "apac.anthropic.claude-sonnet-4-6",
|
||||
name: "APAC Anthropic Claude Sonnet 4.6",
|
||||
input: ["text", "image"],
|
||||
});
|
||||
});
|
||||
});
|
||||
693
extensions/amazon-bedrock/discovery.ts
Normal file
693
extensions/amazon-bedrock/discovery.ts
Normal file
@@ -0,0 +1,693 @@
|
||||
/**
|
||||
* Amazon Bedrock model discovery and implicit provider construction. It merges
|
||||
* foundation models with inference profiles and caches catalog results.
|
||||
*/
|
||||
import type {
|
||||
BedrockClient,
|
||||
ListFoundationModelsCommandOutput,
|
||||
ListInferenceProfilesCommandOutput,
|
||||
} from "@aws-sdk/client-bedrock";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
BedrockDiscoveryConfig,
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
resolveClaudeFable5ModelIdentity,
|
||||
resolveClaudeModelIdentity,
|
||||
supportsClaudeAdaptiveThinking,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
|
||||
import { resolveBedrockConfigApiKey } from "./discovery-shared.js";
|
||||
import { resolveBedrockNativeThinkingLevelMap } from "./thinking-policy.js";
|
||||
|
||||
const log = createSubsystemLogger("bedrock-discovery");
|
||||
|
||||
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600;
|
||||
const DEFAULT_CONTEXT_WINDOW = 32_000;
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known model context windows (Bedrock API does not expose token limits)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bedrock's ListFoundationModels and GetFoundationModel APIs return no token
|
||||
* limit information — only model ID, name, modalities, and lifecycle status.
|
||||
* There is currently no Bedrock API to discover context windows or max output
|
||||
* tokens programmatically.
|
||||
*
|
||||
* This map provides correct context window values for known models so that
|
||||
* session management, compaction thresholds, and context overflow detection
|
||||
* work correctly. If AWS adds token metadata to the API in the future, this
|
||||
* table should become a fallback rather than the primary source.
|
||||
*
|
||||
* Inference profile prefixes (us., eu., ap., global.) are stripped before lookup.
|
||||
*
|
||||
* Sources: https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html
|
||||
* https://platform.claude.com/docs/en/about-claude/models
|
||||
*/
|
||||
const KNOWN_CONTEXT_WINDOWS: Record<string, number> = {
|
||||
// Anthropic Claude
|
||||
"anthropic.claude-fable-5": 1_000_000,
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0": 200_000,
|
||||
"anthropic.claude-opus-4-8": 1_000_000,
|
||||
"anthropic.claude-opus-4-7": 1_000_000,
|
||||
"anthropic.claude-opus-4-6-v1": 1_000_000,
|
||||
"anthropic.claude-opus-4-6-v1:0": 1_000_000,
|
||||
"anthropic.claude-sonnet-4-6": 1_000_000,
|
||||
"anthropic.claude-sonnet-4-6-v1:0": 1_000_000,
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0": 200_000,
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0": 200_000,
|
||||
"anthropic.claude-opus-4-5-20251101-v1:0": 200_000,
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0": 200_000,
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0": 200_000,
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0": 200_000,
|
||||
"anthropic.claude-3-haiku-20240307-v1:0": 200_000,
|
||||
// Amazon Nova
|
||||
"amazon.nova-premier-v1:0": 1_000_000,
|
||||
"amazon.nova-pro-v1:0": 300_000,
|
||||
"amazon.nova-lite-v1:0": 300_000,
|
||||
"amazon.nova-micro-v1:0": 128_000,
|
||||
"amazon.nova-2-lite-v1:0": 300_000,
|
||||
// MiniMax
|
||||
"minimax.minimax-m2.5": 1_000_000,
|
||||
"minimax.minimax-m2.1": 1_000_000,
|
||||
"minimax.minimax-m2": 1_000_000,
|
||||
// Meta Llama 4
|
||||
"meta.llama4-maverick-17b-instruct-v1:0": 1_000_000,
|
||||
"meta.llama4-scout-17b-instruct-v1:0": 512_000,
|
||||
// Meta Llama 3
|
||||
"meta.llama3-3-70b-instruct-v1:0": 128_000,
|
||||
"meta.llama3-2-90b-instruct-v1:0": 128_000,
|
||||
"meta.llama3-2-11b-instruct-v1:0": 128_000,
|
||||
"meta.llama3-2-3b-instruct-v1:0": 128_000,
|
||||
"meta.llama3-2-1b-instruct-v1:0": 128_000,
|
||||
"meta.llama3-1-405b-instruct-v1:0": 128_000,
|
||||
"meta.llama3-1-70b-instruct-v1:0": 128_000,
|
||||
"meta.llama3-1-8b-instruct-v1:0": 128_000,
|
||||
// NVIDIA Nemotron
|
||||
"nvidia.nemotron-super-3-120b": 256_000,
|
||||
"nvidia.nemotron-nano-3-30b": 128_000,
|
||||
"nvidia.nemotron-nano-12b-v2": 128_000,
|
||||
"nvidia.nemotron-nano-9b-v2": 128_000,
|
||||
// Mistral
|
||||
"mistral.mistral-large-3-675b-instruct": 128_000,
|
||||
"mistral.mistral-large-2407-v1:0": 128_000,
|
||||
"mistral.mistral-small-2402-v1:0": 32_000,
|
||||
// DeepSeek
|
||||
"deepseek.r1-v1:0": 128_000,
|
||||
"deepseek.v3.2": 128_000,
|
||||
// Cohere
|
||||
"cohere.command-r-plus-v1:0": 128_000,
|
||||
"cohere.command-r-v1:0": 128_000,
|
||||
// AI21
|
||||
"ai21.jamba-1-5-large-v1:0": 256_000,
|
||||
"ai21.jamba-1-5-mini-v1:0": 256_000,
|
||||
// Google Gemma
|
||||
"google.gemma-3-27b-it": 128_000,
|
||||
"google.gemma-3-12b-it": 128_000,
|
||||
"google.gemma-3-4b-it": 128_000,
|
||||
// GLM
|
||||
"zai.glm-5": 128_000,
|
||||
"zai.glm-4.7": 128_000,
|
||||
"zai.glm-4.7-flash": 128_000,
|
||||
// Qwen
|
||||
"qwen.qwen3-coder-next": 256_000,
|
||||
"qwen.qwen3-coder-30b-a3b-v1:0": 256_000,
|
||||
"qwen.qwen3-32b-v1:0": 128_000,
|
||||
"qwen.qwen3-vl-235b-a22b": 128_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the real context window for a Bedrock model ID.
|
||||
* Strips inference profile prefixes (us., eu., ap., global.) before lookup.
|
||||
*/
|
||||
function resolveKnownContextWindow(modelId: string): number | undefined {
|
||||
const stripped = modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
|
||||
const candidates = [modelId, stripped];
|
||||
for (const candidate of candidates) {
|
||||
if (resolveClaudeFable5ModelIdentity({ id: candidate })) {
|
||||
return 1_000_000;
|
||||
}
|
||||
if (/(?:^|[/.:])anthropic\.claude-opus-4[.-]8(?:$|[-.:/])/i.test(candidate)) {
|
||||
return 1_000_000;
|
||||
}
|
||||
if (KNOWN_CONTEXT_WINDOWS[candidate] !== undefined) {
|
||||
return KNOWN_CONTEXT_WINDOWS[candidate];
|
||||
}
|
||||
const withoutVersionSuffix = candidate.replace(/:0$/, "");
|
||||
if (
|
||||
withoutVersionSuffix !== candidate &&
|
||||
KNOWN_CONTEXT_WINDOWS[withoutVersionSuffix] !== undefined
|
||||
) {
|
||||
return KNOWN_CONTEXT_WINDOWS[withoutVersionSuffix];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isKnownClaudeMythosPreviewModelId(modelId: string): boolean {
|
||||
const stripped = modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
|
||||
return [modelId, stripped].some((candidate) =>
|
||||
/(?:^|[/.:])anthropic\.claude-mythos-preview(?:$|[-.:/])/i.test(candidate),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveKnownThinkingLevelMap(
|
||||
modelId: string,
|
||||
): ModelDefinitionConfig["thinkingLevelMap"] | undefined {
|
||||
return resolveBedrockNativeThinkingLevelMap(modelId);
|
||||
}
|
||||
|
||||
function resolveKnownMaxTokens(modelId: string): number | undefined {
|
||||
return resolveClaudeFable5ModelIdentity({ id: modelId }) ? 128_000 : undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
type BedrockModelSummary = NonNullable<ListFoundationModelsCommandOutput["modelSummaries"]>[number];
|
||||
|
||||
type InferenceProfileSummary = NonNullable<
|
||||
ListInferenceProfilesCommandOutput["inferenceProfileSummaries"]
|
||||
>[number];
|
||||
|
||||
type BedrockDiscoverySdk = {
|
||||
createClient(region: string): BedrockClient;
|
||||
createListFoundationModelsCommand(): unknown;
|
||||
createListInferenceProfilesCommand(input: { nextToken?: string }): unknown;
|
||||
};
|
||||
|
||||
async function loadBedrockDiscoverySdk(): Promise<BedrockDiscoverySdk> {
|
||||
const { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } =
|
||||
await import("@aws-sdk/client-bedrock");
|
||||
return {
|
||||
createClient: (region) => new BedrockClient({ region }),
|
||||
createListFoundationModelsCommand: () => new ListFoundationModelsCommand({}),
|
||||
createListInferenceProfilesCommand: (input) => new ListInferenceProfilesCommand(input),
|
||||
};
|
||||
}
|
||||
|
||||
function createInjectedClientDiscoverySdk(): BedrockDiscoverySdk {
|
||||
class ListFoundationModelsCommand {
|
||||
constructor(readonly input: Record<string, unknown> = {}) {}
|
||||
}
|
||||
class ListInferenceProfilesCommand {
|
||||
constructor(readonly input: Record<string, unknown> = {}) {}
|
||||
}
|
||||
return {
|
||||
createClient() {
|
||||
throw new Error("clientFactory is required for injected Bedrock discovery commands");
|
||||
},
|
||||
createListFoundationModelsCommand: () => new ListFoundationModelsCommand({}),
|
||||
createListInferenceProfilesCommand: (input) => new ListInferenceProfilesCommand(input),
|
||||
};
|
||||
}
|
||||
|
||||
type BedrockDiscoveryCacheEntry = {
|
||||
expiresAt: number;
|
||||
value?: ModelDefinitionConfig[];
|
||||
inFlight?: Promise<ModelDefinitionConfig[]>;
|
||||
};
|
||||
|
||||
const discoveryCache = new Map<string, BedrockDiscoveryCacheEntry>();
|
||||
let hasLoggedBedrockError = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeProviderFilter(filter?: string[]): string[] {
|
||||
if (!filter || filter.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const normalized = new Set(
|
||||
filter
|
||||
.map((entry) => normalizeOptionalLowercaseString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
);
|
||||
return Array.from(normalized).toSorted();
|
||||
}
|
||||
|
||||
function buildCacheKey(params: {
|
||||
region: string;
|
||||
providerFilter: string[];
|
||||
refreshIntervalSeconds: number;
|
||||
defaultContextWindow: number;
|
||||
defaultMaxTokens: number;
|
||||
}): string {
|
||||
return JSON.stringify(params);
|
||||
}
|
||||
|
||||
function includesTextModalities(modalities?: Array<string>): boolean {
|
||||
return (modalities ?? []).some((entry) => normalizeOptionalLowercaseString(entry) === "text");
|
||||
}
|
||||
|
||||
function isActive(summary: BedrockModelSummary): boolean {
|
||||
const status = summary.modelLifecycle?.status;
|
||||
return typeof status === "string" ? status.toUpperCase() === "ACTIVE" : false;
|
||||
}
|
||||
|
||||
function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image"> {
|
||||
const inputs = summary.inputModalities ?? [];
|
||||
const mapped = new Set<"text" | "image">();
|
||||
for (const modality of inputs) {
|
||||
const lower = normalizeOptionalLowercaseString(modality);
|
||||
if (lower === "text") {
|
||||
mapped.add("text");
|
||||
}
|
||||
if (lower === "image") {
|
||||
mapped.add("image");
|
||||
}
|
||||
}
|
||||
if (mapped.size === 0) {
|
||||
mapped.add("text");
|
||||
}
|
||||
return Array.from(mapped);
|
||||
}
|
||||
|
||||
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
|
||||
if (supportsClaudeAdaptiveThinking({ id: summary.modelId })) {
|
||||
return true;
|
||||
}
|
||||
const haystack = normalizeLowercaseStringOrEmpty(
|
||||
`${summary.modelId ?? ""} ${summary.modelName ?? ""}`,
|
||||
);
|
||||
return haystack.includes("reasoning") || haystack.includes("thinking");
|
||||
}
|
||||
|
||||
function resolveDefaultContextWindow(config?: BedrockDiscoveryConfig): number {
|
||||
const value = Math.floor(config?.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW);
|
||||
return value > 0 ? value : DEFAULT_CONTEXT_WINDOW;
|
||||
}
|
||||
|
||||
function resolveDefaultMaxTokens(config?: BedrockDiscoveryConfig): number {
|
||||
const value = Math.floor(config?.defaultMaxTokens ?? DEFAULT_MAX_TOKENS);
|
||||
return value > 0 ? value : DEFAULT_MAX_TOKENS;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Foundation model helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function matchesProviderFilter(summary: BedrockModelSummary, filter: string[]): boolean {
|
||||
if (filter.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const providerName =
|
||||
summary.providerName ??
|
||||
(typeof summary.modelId === "string" ? summary.modelId.split(".")[0] : undefined);
|
||||
const normalized = normalizeOptionalLowercaseString(providerName);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return filter.includes(normalized);
|
||||
}
|
||||
|
||||
function shouldIncludeSummary(summary: BedrockModelSummary, filter: string[]): boolean {
|
||||
if (!summary.modelId?.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesProviderFilter(summary, filter)) {
|
||||
return false;
|
||||
}
|
||||
if (summary.responseStreamingSupported !== true) {
|
||||
return false;
|
||||
}
|
||||
if (isKnownClaudeMythosPreviewModelId(summary.modelId)) {
|
||||
return false;
|
||||
}
|
||||
if (!includesTextModalities(summary.outputModalities)) {
|
||||
return false;
|
||||
}
|
||||
if (!isActive(summary)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toModelDefinition(
|
||||
summary: BedrockModelSummary,
|
||||
defaults: { contextWindow: number; maxTokens: number },
|
||||
): ModelDefinitionConfig {
|
||||
const id = summary.modelId?.trim() ?? "";
|
||||
const thinkingLevelMap = resolveKnownThinkingLevelMap(id);
|
||||
return {
|
||||
id,
|
||||
name: summary.modelName?.trim() || id,
|
||||
reasoning: inferReasoningSupport(summary),
|
||||
input: mapInputModalities(summary),
|
||||
cost: DEFAULT_COST,
|
||||
contextWindow: resolveKnownContextWindow(id) ?? defaults.contextWindow,
|
||||
maxTokens: resolveKnownMaxTokens(id) ?? defaults.maxTokens,
|
||||
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inference profile helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the base foundation model ID from an inference profile.
|
||||
*
|
||||
* System-defined profiles use a region prefix:
|
||||
* "us.anthropic.claude-sonnet-4-6" → "anthropic.claude-sonnet-4-6"
|
||||
*
|
||||
* Application profiles carry the model ARN in their models[] array:
|
||||
* models[0].modelArn = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6"
|
||||
* → "anthropic.claude-sonnet-4-6"
|
||||
*/
|
||||
function resolveBaseModelId(profile: InferenceProfileSummary): string | undefined {
|
||||
const firstArn = profile.models?.[0]?.modelArn;
|
||||
if (firstArn) {
|
||||
const arnMatch = /foundation-model\/(.+)$/.exec(firstArn);
|
||||
if (arnMatch) {
|
||||
return arnMatch[1];
|
||||
}
|
||||
}
|
||||
if (profile.type === "SYSTEM_DEFINED") {
|
||||
const id = profile.inferenceProfileId ?? "";
|
||||
const prefixMatch = /^(?:us|eu|ap|apac|au|jp|global)\.(.+)$/i.exec(id);
|
||||
if (prefixMatch) {
|
||||
return prefixMatch[1];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw inference profile summaries from the Bedrock control plane.
|
||||
* Handles pagination. Best-effort: silently returns empty array if IAM lacks
|
||||
* bedrock:ListInferenceProfiles permission.
|
||||
*/
|
||||
async function fetchInferenceProfileSummaries(
|
||||
client: BedrockClient,
|
||||
createListInferenceProfilesCommand: BedrockDiscoverySdk["createListInferenceProfilesCommand"],
|
||||
): Promise<InferenceProfileSummary[]> {
|
||||
try {
|
||||
const profiles: InferenceProfileSummary[] = [];
|
||||
let nextToken: string | undefined;
|
||||
do {
|
||||
const response: ListInferenceProfilesCommandOutput = await client.send(
|
||||
createListInferenceProfilesCommand({ nextToken }) as never,
|
||||
);
|
||||
for (const summary of response.inferenceProfileSummaries ?? []) {
|
||||
profiles.push(summary);
|
||||
}
|
||||
nextToken = response.nextToken;
|
||||
} while (nextToken);
|
||||
return profiles;
|
||||
} catch (error) {
|
||||
log.debug?.("Skipping inference profile discovery", {
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw inference profile summaries into model definitions.
|
||||
*
|
||||
* Each profile inherits capabilities (modalities, reasoning, context window,
|
||||
* cost) from its underlying foundation model. This ensures that
|
||||
* "us.anthropic.claude-sonnet-4-6" has the same capabilities as
|
||||
* "anthropic.claude-sonnet-4-6" — including image input, reasoning support,
|
||||
* and token limits.
|
||||
*
|
||||
* When the foundation model isn't found in the map (e.g. the model is only
|
||||
* available via inference profiles in this region), safe defaults are used.
|
||||
*/
|
||||
function resolveInferenceProfiles(
|
||||
profiles: InferenceProfileSummary[],
|
||||
defaults: { contextWindow: number; maxTokens: number },
|
||||
providerFilter: string[],
|
||||
foundationModels: Map<string, ModelDefinitionConfig>,
|
||||
): ModelDefinitionConfig[] {
|
||||
const discovered: ModelDefinitionConfig[] = [];
|
||||
for (const profile of profiles) {
|
||||
if (!profile.inferenceProfileId?.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (profile.status !== "ACTIVE") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply provider filter: check if any of the underlying models match.
|
||||
if (providerFilter.length > 0) {
|
||||
const models = profile.models ?? [];
|
||||
const matchesFilter = models.some((m) => {
|
||||
const provider = m.modelArn?.split("/")?.[1]?.split(".")?.[0];
|
||||
return provider
|
||||
? providerFilter.includes(normalizeOptionalLowercaseString(provider) ?? "")
|
||||
: false;
|
||||
});
|
||||
if (!matchesFilter) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Look up the underlying foundation model to inherit its capabilities.
|
||||
const baseModelId = resolveBaseModelId(profile);
|
||||
if (isKnownClaudeMythosPreviewModelId(baseModelId ?? profile.inferenceProfileId)) {
|
||||
continue;
|
||||
}
|
||||
const baseModel = baseModelId
|
||||
? foundationModels.get(normalizeLowercaseStringOrEmpty(baseModelId))
|
||||
: undefined;
|
||||
const knownThinkingLevelMap = resolveKnownThinkingLevelMap(
|
||||
baseModelId ?? profile.inferenceProfileId,
|
||||
);
|
||||
const canonicalClaudeId = resolveClaudeModelIdentity({ id: baseModelId });
|
||||
|
||||
discovered.push({
|
||||
id: profile.inferenceProfileId,
|
||||
name: profile.inferenceProfileName?.trim() || profile.inferenceProfileId,
|
||||
reasoning:
|
||||
baseModel?.reasoning ??
|
||||
supportsClaudeAdaptiveThinking({ id: baseModelId ?? profile.inferenceProfileId }),
|
||||
input: baseModel?.input ?? ["text"],
|
||||
cost: baseModel?.cost ?? DEFAULT_COST,
|
||||
contextWindow:
|
||||
baseModel?.contextWindow ??
|
||||
resolveKnownContextWindow(baseModelId ?? profile.inferenceProfileId ?? "") ??
|
||||
defaults.contextWindow,
|
||||
maxTokens:
|
||||
baseModel?.maxTokens ??
|
||||
resolveKnownMaxTokens(baseModelId ?? profile.inferenceProfileId) ??
|
||||
defaults.maxTokens,
|
||||
...(baseModel?.thinkingLevelMap || knownThinkingLevelMap
|
||||
? { thinkingLevelMap: baseModel?.thinkingLevelMap ?? knownThinkingLevelMap }
|
||||
: {}),
|
||||
...(canonicalClaudeId.startsWith("claude-")
|
||||
? { params: { canonicalModelId: canonicalClaudeId } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return discovered;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Reset Bedrock discovery cache for tests. */
|
||||
export function resetBedrockDiscoveryCacheForTest(): void {
|
||||
discoveryCache.clear();
|
||||
hasLoggedBedrockError = false;
|
||||
}
|
||||
|
||||
/** Discover Bedrock models and inference profiles for one region/config. */
|
||||
export async function discoverBedrockModels(params: {
|
||||
region: string;
|
||||
config?: BedrockDiscoveryConfig;
|
||||
now?: () => number;
|
||||
clientFactory?: (region: string) => BedrockClient;
|
||||
}): Promise<ModelDefinitionConfig[]> {
|
||||
const refreshIntervalSeconds = Math.max(
|
||||
0,
|
||||
Math.floor(params.config?.refreshInterval ?? DEFAULT_REFRESH_INTERVAL_SECONDS),
|
||||
);
|
||||
const providerFilter = normalizeProviderFilter(params.config?.providerFilter);
|
||||
const defaultContextWindow = resolveDefaultContextWindow(params.config);
|
||||
const defaultMaxTokens = resolveDefaultMaxTokens(params.config);
|
||||
const cacheKey = buildCacheKey({
|
||||
region: params.region,
|
||||
providerFilter,
|
||||
refreshIntervalSeconds,
|
||||
defaultContextWindow,
|
||||
defaultMaxTokens,
|
||||
});
|
||||
const now = params.now?.() ?? Date.now();
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const cached = discoveryCache.get(cacheKey);
|
||||
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
|
||||
if (cached.value) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached.inFlight) {
|
||||
return cached.inFlight;
|
||||
}
|
||||
}
|
||||
if (cached) {
|
||||
discoveryCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
const sdk = params.clientFactory
|
||||
? createInjectedClientDiscoverySdk()
|
||||
: await loadBedrockDiscoverySdk();
|
||||
const clientFactory = params.clientFactory ?? ((region: string) => sdk.createClient(region));
|
||||
if (!params.clientFactory) {
|
||||
await refreshAwsSharedConfigCacheForBedrock();
|
||||
}
|
||||
const client = clientFactory(params.region);
|
||||
|
||||
const discoveryPromise = (async () => {
|
||||
// Discover foundation models and inference profiles in parallel.
|
||||
// Both API calls are independent, but we need the foundation model data
|
||||
// to resolve inference profile capabilities — so we fetch in parallel,
|
||||
// then build the lookup map before processing profiles.
|
||||
const [rawFoundationResponse, profileSummaries] = await Promise.all([
|
||||
client.send(sdk.createListFoundationModelsCommand() as never),
|
||||
fetchInferenceProfileSummaries(client, (input) =>
|
||||
sdk.createListInferenceProfilesCommand(input),
|
||||
),
|
||||
]);
|
||||
const foundationResponse = rawFoundationResponse as ListFoundationModelsCommandOutput;
|
||||
|
||||
const discovered: ModelDefinitionConfig[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const foundationModels = new Map<string, ModelDefinitionConfig>();
|
||||
|
||||
// Foundation models first — build both the results list and the lookup map.
|
||||
for (const summary of foundationResponse.modelSummaries ?? []) {
|
||||
if (!shouldIncludeSummary(summary, providerFilter)) {
|
||||
continue;
|
||||
}
|
||||
const def = toModelDefinition(summary, {
|
||||
contextWindow: defaultContextWindow,
|
||||
maxTokens: defaultMaxTokens,
|
||||
});
|
||||
discovered.push(def);
|
||||
const normalizedId = normalizeLowercaseStringOrEmpty(def.id);
|
||||
seenIds.add(normalizedId);
|
||||
foundationModels.set(normalizedId, def);
|
||||
}
|
||||
|
||||
// Merge inference profiles — inherit capabilities from foundation models.
|
||||
const inferenceProfiles = resolveInferenceProfiles(
|
||||
profileSummaries,
|
||||
{ contextWindow: defaultContextWindow, maxTokens: defaultMaxTokens },
|
||||
providerFilter,
|
||||
foundationModels,
|
||||
);
|
||||
for (const profile of inferenceProfiles) {
|
||||
const normalizedId = normalizeLowercaseStringOrEmpty(profile.id);
|
||||
if (!seenIds.has(normalizedId)) {
|
||||
discovered.push(profile);
|
||||
seenIds.add(normalizedId);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: global cross-region profiles first (recommended for most users —
|
||||
// better capacity, automatic failover, no data sovereignty constraints),
|
||||
// then remaining profiles/models alphabetically.
|
||||
return discovered.toSorted((a, b) => {
|
||||
const aGlobal = a.id.startsWith("global.") ? 0 : 1;
|
||||
const bGlobal = b.id.startsWith("global.") ? 0 : 1;
|
||||
if (aGlobal !== bGlobal) {
|
||||
return aGlobal - bGlobal;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
})();
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, { nowMs: now });
|
||||
if (expiresAt !== undefined) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt,
|
||||
inFlight: discoveryPromise,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await discoveryPromise;
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, {
|
||||
nowMs: now,
|
||||
});
|
||||
if (expiresAt !== undefined) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
discoveryCache.delete(cacheKey);
|
||||
}
|
||||
if (!hasLoggedBedrockError) {
|
||||
hasLoggedBedrockError = true;
|
||||
log.warn("Failed to discover Bedrock models", {
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the implicit Bedrock provider config from env, plugin config, and discovery. */
|
||||
export async function resolveImplicitBedrockProvider(params: {
|
||||
pluginConfig?: { discovery?: BedrockDiscoveryConfig };
|
||||
env?: NodeJS.ProcessEnv;
|
||||
clientFactory?: (region: string) => BedrockClient;
|
||||
}): Promise<ModelProviderConfig | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const discoveryConfig = params.pluginConfig?.discovery;
|
||||
const enabled = discoveryConfig?.enabled;
|
||||
const hasAwsCreds = resolveBedrockConfigApiKey(env) !== undefined;
|
||||
if (enabled === false) {
|
||||
return null;
|
||||
}
|
||||
if (enabled !== true && !hasAwsCreds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
|
||||
const models = await discoverBedrockModels({
|
||||
region,
|
||||
config: discoveryConfig,
|
||||
clientFactory: params.clientFactory,
|
||||
});
|
||||
if (models.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `https://bedrock-runtime.${region}.amazonaws.com`,
|
||||
api: "bedrock-converse-stream",
|
||||
auth: "aws-sdk",
|
||||
models,
|
||||
};
|
||||
}
|
||||
154
extensions/amazon-bedrock/embedding-provider.test.ts
Normal file
154
extensions/amazon-bedrock/embedding-provider.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// Amazon Bedrock tests cover embedding provider plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { testing, hasAwsCredentials } from "./embedding-provider.js";
|
||||
|
||||
describe("hasAwsCredentials", () => {
|
||||
it("accepts static AWS key credentials without loading the credential chain", async () => {
|
||||
const loadCredentialProvider = vi.fn();
|
||||
|
||||
await expect(
|
||||
hasAwsCredentials(
|
||||
{
|
||||
AWS_ACCESS_KEY_ID: "access-key",
|
||||
AWS_SECRET_ACCESS_KEY: "secret-key",
|
||||
},
|
||||
loadCredentialProvider,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(loadCredentialProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the Bedrock bearer token without loading the credential chain", async () => {
|
||||
const loadCredentialProvider = vi.fn();
|
||||
|
||||
await expect(
|
||||
hasAwsCredentials(
|
||||
{
|
||||
AWS_BEARER_TOKEN_BEDROCK: "bearer-token",
|
||||
},
|
||||
loadCredentialProvider,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(loadCredentialProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires AWS profile credentials to resolve through the credential chain", async () => {
|
||||
const loadCredentialProvider = vi.fn().mockResolvedValue({
|
||||
defaultProvider: () => async () => ({ accessKeyId: "resolved-access-key" }),
|
||||
});
|
||||
|
||||
await expect(hasAwsCredentials({ AWS_PROFILE: "work" }, loadCredentialProvider)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(loadCredentialProvider).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects AWS profile markers when the credential chain cannot resolve", async () => {
|
||||
const loadCredentialProvider = vi.fn().mockResolvedValue({
|
||||
defaultProvider: () => async () => {
|
||||
throw new Error("Could not load credentials from any providers");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
hasAwsCredentials({ AWS_PROFILE: "missing" }, loadCredentialProvider),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the AWS credential provider package is unavailable", async () => {
|
||||
const loadCredentialProvider = vi.fn().mockResolvedValue(null);
|
||||
|
||||
await expect(hasAwsCredentials({}, loadCredentialProvider)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bedrock embedding response parsers", () => {
|
||||
it("wraps malformed single embedding JSON", () => {
|
||||
expect(() => testing.parseSingle("titan-v2", "{not json")).toThrow(
|
||||
"Amazon Bedrock embedding response returned malformed JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps malformed batch embedding JSON", () => {
|
||||
expect(() => testing.parseCohereBatch("cohere-v3", "{not json")).toThrow(
|
||||
"Amazon Bedrock embedding response returned malformed JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-object embedding JSON", () => {
|
||||
expect(() => testing.parseSingle("titan-v2", "[]")).toThrow(
|
||||
"Amazon Bedrock embedding response returned malformed JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing single embedding vectors", () => {
|
||||
expect(() => testing.parseSingle("titan-v2", "{}")).toThrow(
|
||||
"Amazon Bedrock embedding response returned malformed JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects wrong single embedding vector element types", () => {
|
||||
expect(() => testing.parseSingle("titan-v2", '{"embedding":[1,"bad"]}')).toThrow(
|
||||
"Amazon Bedrock embedding response returned malformed JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing batch embedding vectors", () => {
|
||||
expect(() => testing.parseCohereBatch("cohere-v3", "{}")).toThrow(
|
||||
"Amazon Bedrock embedding response returned malformed JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects wrong batch embedding vector shapes", () => {
|
||||
expect(() =>
|
||||
testing.parseCohereBatch("cohere-v3", '{"embeddings":[[1],{"bad":true}]}'),
|
||||
).toThrow("Amazon Bedrock embedding response returned malformed JSON");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripInferenceProfilePrefix", () => {
|
||||
it("strips global prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("global.cohere.embed-v4:0")).toBe(
|
||||
"cohere.embed-v4:0",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips us prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("us.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips eu prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("eu.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips ap prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("ap.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips apac prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("apac.cohere.embed-v4:0")).toBe(
|
||||
"cohere.embed-v4:0",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips au prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("au.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips jp prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("jp.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("returns unchanged model ID without prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("returns unchanged model ID for amazon.titan-embed-text-v2:0", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("amazon.titan-embed-text-v2:0")).toBe(
|
||||
"amazon.titan-embed-text-v2:0",
|
||||
);
|
||||
});
|
||||
});
|
||||
479
extensions/amazon-bedrock/embedding-provider.ts
Normal file
479
extensions/amazon-bedrock/embedding-provider.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Amazon Bedrock embedding provider runtime. It normalizes model-specific
|
||||
* request/response shapes across Titan, Cohere, Nova, and TwelveLabs models.
|
||||
*/
|
||||
import {
|
||||
debugEmbeddingsLog,
|
||||
sanitizeAndNormalizeEmbedding,
|
||||
type MemoryEmbeddingProvider,
|
||||
type MemoryEmbeddingProviderCreateOptions,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
asOptionalRecord as asRecord,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BedrockEmbeddingClient = {
|
||||
region: string;
|
||||
model: string;
|
||||
dimensions?: number;
|
||||
};
|
||||
|
||||
/** Default Bedrock embedding model used when no explicit model is configured. */
|
||||
export const DEFAULT_BEDROCK_EMBEDDING_MODEL = "amazon.titan-embed-text-v2:0";
|
||||
|
||||
/** Request/response format family — each has a different API shape. */
|
||||
type Family = "titan-v1" | "titan-v2" | "cohere-v3" | "cohere-v4" | "nova" | "twelvelabs";
|
||||
|
||||
interface ModelSpec {
|
||||
maxTokens: number;
|
||||
dims: number;
|
||||
validDims?: number[];
|
||||
family: Family;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODELS: Record<string, ModelSpec> = {
|
||||
"amazon.titan-embed-text-v2:0": {
|
||||
maxTokens: 8192,
|
||||
dims: 1024,
|
||||
validDims: [256, 512, 1024],
|
||||
family: "titan-v2",
|
||||
},
|
||||
"amazon.titan-embed-text-v1": { maxTokens: 8000, dims: 1536, family: "titan-v1" },
|
||||
"amazon.titan-embed-g1-text-02": { maxTokens: 8000, dims: 1536, family: "titan-v1" },
|
||||
"amazon.titan-embed-image-v1": { maxTokens: 128, dims: 1024, family: "titan-v1" },
|
||||
"cohere.embed-english-v3": { maxTokens: 512, dims: 1024, family: "cohere-v3" },
|
||||
"cohere.embed-multilingual-v3": { maxTokens: 512, dims: 1024, family: "cohere-v3" },
|
||||
"cohere.embed-v4:0": {
|
||||
maxTokens: 128000,
|
||||
dims: 1536,
|
||||
validDims: [256, 384, 512, 768, 1024, 1536],
|
||||
family: "cohere-v4",
|
||||
},
|
||||
"amazon.nova-2-multimodal-embeddings-v1:0": {
|
||||
maxTokens: 8192,
|
||||
dims: 1024,
|
||||
validDims: [256, 384, 1024, 3072],
|
||||
family: "nova",
|
||||
},
|
||||
"twelvelabs.marengo-embed-2-7-v1:0": { maxTokens: 512, dims: 1024, family: "twelvelabs" },
|
||||
"twelvelabs.marengo-embed-3-0-v1:0": { maxTokens: 512, dims: 512, family: "twelvelabs" },
|
||||
};
|
||||
|
||||
/** Strip AWS inference profile prefix (us., eu., ap., apac., au., jp., global.) from model ID. */
|
||||
function stripInferenceProfilePrefix(modelId: string): string {
|
||||
return modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
|
||||
}
|
||||
|
||||
/** Resolve spec, stripping throughput suffixes like `:2:8k` or `:0:512`. */
|
||||
function resolveSpec(modelId: string): ModelSpec | undefined {
|
||||
const bare = stripInferenceProfilePrefix(modelId);
|
||||
if (MODELS[bare]) {
|
||||
return MODELS[bare];
|
||||
}
|
||||
const parts = bare.split(":");
|
||||
for (let i = parts.length - 1; i >= 1; i--) {
|
||||
const spec = MODELS[parts.slice(0, i).join(":")];
|
||||
if (spec) {
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Infer family from model ID prefix when not in catalog. */
|
||||
function inferFamily(modelId: string): Family {
|
||||
const id = normalizeLowercaseStringOrEmpty(stripInferenceProfilePrefix(modelId));
|
||||
if (id.startsWith("amazon.titan-embed-text-v2")) {
|
||||
return "titan-v2";
|
||||
}
|
||||
if (id.startsWith("amazon.titan-embed")) {
|
||||
return "titan-v1";
|
||||
}
|
||||
if (id.startsWith("amazon.nova")) {
|
||||
return "nova";
|
||||
}
|
||||
if (id.startsWith("cohere.embed-v4")) {
|
||||
return "cohere-v4";
|
||||
}
|
||||
if (id.startsWith("cohere.embed")) {
|
||||
return "cohere-v3";
|
||||
}
|
||||
if (id.startsWith("twelvelabs.")) {
|
||||
return "twelvelabs";
|
||||
}
|
||||
return "titan-v1"; // safest default — simplest request format
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AWS SDK lazy loader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SdkClient = import("@aws-sdk/client-bedrock-runtime").BedrockRuntimeClient;
|
||||
type SdkCommand = import("@aws-sdk/client-bedrock-runtime").InvokeModelCommand;
|
||||
|
||||
interface AwsSdk {
|
||||
BedrockRuntimeClient: new (config: { region: string }) => SdkClient;
|
||||
InvokeModelCommand: new (input: {
|
||||
modelId: string;
|
||||
body: string;
|
||||
contentType: string;
|
||||
accept: string;
|
||||
}) => SdkCommand;
|
||||
}
|
||||
|
||||
interface AwsCredentialProviderSdk {
|
||||
defaultProvider: (init?: { timeout?: number; maxRetries?: number }) => () => Promise<{
|
||||
accessKeyId?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
type AwsCredentialProviderLoader = () => Promise<AwsCredentialProviderSdk | null>;
|
||||
|
||||
let sdkCache: AwsSdk | null = null;
|
||||
let credentialProviderSdkCache: AwsCredentialProviderSdk | null | undefined;
|
||||
|
||||
async function loadSdk(): Promise<AwsSdk> {
|
||||
if (sdkCache) {
|
||||
return sdkCache;
|
||||
}
|
||||
try {
|
||||
sdkCache = (await import("@aws-sdk/client-bedrock-runtime")) as unknown as AwsSdk;
|
||||
return sdkCache;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"No API key found for provider bedrock: @aws-sdk/client-bedrock-runtime is not installed. " +
|
||||
"Install it with: npm install @aws-sdk/client-bedrock-runtime",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCredentialProviderSdk(): Promise<AwsCredentialProviderSdk | null> {
|
||||
if (credentialProviderSdkCache !== undefined) {
|
||||
return credentialProviderSdkCache;
|
||||
}
|
||||
try {
|
||||
credentialProviderSdkCache =
|
||||
(await import("@aws-sdk/credential-provider-node")) as unknown as AwsCredentialProviderSdk;
|
||||
} catch {
|
||||
credentialProviderSdkCache = null;
|
||||
}
|
||||
return credentialProviderSdkCache;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODEL_PREFIX_RE = /^(?:bedrock|amazon-bedrock|aws)\//;
|
||||
const REGION_RE = /bedrock-runtime\.([a-z0-9-]+)\./;
|
||||
|
||||
function normalizeBedrockEmbeddingModel(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
return trimmed ? trimmed.replace(MODEL_PREFIX_RE, "") : DEFAULT_BEDROCK_EMBEDDING_MODEL;
|
||||
}
|
||||
|
||||
function regionFromUrl(url: string | undefined): string | undefined {
|
||||
return url?.trim() ? REGION_RE.exec(url)?.[1] : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildBody(family: Family, text: string, dims?: number): string {
|
||||
switch (family) {
|
||||
case "titan-v2": {
|
||||
const b: Record<string, unknown> = { inputText: text };
|
||||
if (dims != null) {
|
||||
b.dimensions = dims;
|
||||
b.normalize = true;
|
||||
}
|
||||
return JSON.stringify(b);
|
||||
}
|
||||
case "titan-v1":
|
||||
return JSON.stringify({ inputText: text });
|
||||
case "nova":
|
||||
return JSON.stringify({
|
||||
taskType: "SINGLE_EMBEDDING",
|
||||
singleEmbeddingParams: {
|
||||
embeddingPurpose: "GENERIC_INDEX",
|
||||
embeddingDimension: dims ?? 1024,
|
||||
text: { truncationMode: "END", value: text },
|
||||
},
|
||||
});
|
||||
case "twelvelabs":
|
||||
return JSON.stringify({ inputType: "text", text: { inputText: text } });
|
||||
default:
|
||||
return JSON.stringify({ inputText: text });
|
||||
}
|
||||
}
|
||||
|
||||
function buildCohereBody(
|
||||
family: Family,
|
||||
texts: string[],
|
||||
inputType: "search_query" | "search_document",
|
||||
dims?: number,
|
||||
): string {
|
||||
const body: Record<string, unknown> = { texts, input_type: inputType, truncate: "END" };
|
||||
if (family === "cohere-v4") {
|
||||
body.embedding_types = ["float"];
|
||||
if (dims != null) {
|
||||
body.output_dimension = dims;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response parsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BedrockEmbeddingResponseJson = {
|
||||
embedding?: unknown;
|
||||
embeddings?: unknown;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
function parseBedrockEmbeddingResponseJson(raw: string): BedrockEmbeddingResponseJson {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Amazon Bedrock embedding response returned malformed JSON");
|
||||
}
|
||||
return parsed as BedrockEmbeddingResponseJson;
|
||||
} catch {
|
||||
throw new Error("Amazon Bedrock embedding response returned malformed JSON");
|
||||
}
|
||||
}
|
||||
|
||||
function malformedBedrockEmbeddingResponse(): Error {
|
||||
return new Error("Amazon Bedrock embedding response returned malformed JSON");
|
||||
}
|
||||
|
||||
function asNumberArray(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw malformedBedrockEmbeddingResponse();
|
||||
}
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== "number" || !Number.isFinite(entry)) {
|
||||
throw malformedBedrockEmbeddingResponse();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asNumberArrayBatch(value: unknown): number[][] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw malformedBedrockEmbeddingResponse();
|
||||
}
|
||||
return value.map((entry) => asNumberArray(entry));
|
||||
}
|
||||
|
||||
function parseSingle(family: Family, raw: string): number[] {
|
||||
const data = parseBedrockEmbeddingResponseJson(raw);
|
||||
switch (family) {
|
||||
case "nova":
|
||||
return asNumberArray(Array.isArray(data.embeddings) ? data.embeddings[0]?.embedding : null);
|
||||
case "twelvelabs": {
|
||||
if (Array.isArray(data.data)) {
|
||||
return asNumberArray(asRecord(data.data[0])?.embedding);
|
||||
}
|
||||
const dataRecord = asRecord(data.data);
|
||||
if (dataRecord) {
|
||||
return asNumberArray(dataRecord.embedding);
|
||||
}
|
||||
return asNumberArray(data.embedding);
|
||||
}
|
||||
default:
|
||||
return asNumberArray(data.embedding);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCohereBatch(family: Family, raw: string): number[][] {
|
||||
const data = parseBedrockEmbeddingResponseJson(raw);
|
||||
const embeddings = data.embeddings;
|
||||
if (!embeddings) {
|
||||
throw malformedBedrockEmbeddingResponse();
|
||||
}
|
||||
if (family === "cohere-v4" && !Array.isArray(embeddings)) {
|
||||
const embeddingRecord = asRecord(embeddings);
|
||||
if (!embeddingRecord) {
|
||||
throw malformedBedrockEmbeddingResponse();
|
||||
}
|
||||
return asNumberArrayBatch(embeddingRecord.float);
|
||||
}
|
||||
return asNumberArrayBatch(embeddings);
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
parseCohereBatch,
|
||||
parseSingle,
|
||||
stripInferenceProfilePrefix,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createBedrockEmbeddingProvider(
|
||||
options: MemoryEmbeddingProviderCreateOptions,
|
||||
): Promise<{ provider: MemoryEmbeddingProvider; client: BedrockEmbeddingClient }> {
|
||||
const client = resolveBedrockEmbeddingClient(options);
|
||||
const { BedrockRuntimeClient, InvokeModelCommand } = await loadSdk();
|
||||
const spec = resolveSpec(client.model);
|
||||
const family = spec?.family ?? inferFamily(client.model);
|
||||
|
||||
debugEmbeddingsLog("memory embeddings: bedrock client", {
|
||||
region: client.region,
|
||||
model: client.model,
|
||||
dimensions: client.dimensions,
|
||||
family,
|
||||
});
|
||||
|
||||
const invoke = async (body: string, signal?: AbortSignal): Promise<string> => {
|
||||
await refreshAwsSharedConfigCacheForBedrock();
|
||||
const sdk = new BedrockRuntimeClient({ region: client.region });
|
||||
try {
|
||||
const res = await sdk.send(
|
||||
new InvokeModelCommand({
|
||||
modelId: client.model,
|
||||
body,
|
||||
contentType: "application/json",
|
||||
accept: "application/json",
|
||||
}),
|
||||
signal ? { abortSignal: signal } : undefined,
|
||||
);
|
||||
return new TextDecoder().decode(res.body);
|
||||
} finally {
|
||||
sdk.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const isCohere = family === "cohere-v3" || family === "cohere-v4";
|
||||
|
||||
const embedSingle = async (text: string, signal?: AbortSignal): Promise<number[]> => {
|
||||
const raw = await invoke(buildBody(family, text, client.dimensions), signal);
|
||||
return sanitizeAndNormalizeEmbedding(parseSingle(family, raw));
|
||||
};
|
||||
|
||||
const embedCohere = async (
|
||||
texts: string[],
|
||||
inputType: "search_query" | "search_document",
|
||||
signal?: AbortSignal,
|
||||
): Promise<number[][]> => {
|
||||
const raw = await invoke(buildCohereBody(family, texts, inputType, client.dimensions), signal);
|
||||
return parseCohereBatch(family, raw).map((e) => sanitizeAndNormalizeEmbedding(e));
|
||||
};
|
||||
|
||||
const embedQuery = async (
|
||||
text: string,
|
||||
optionsValue?: { signal?: AbortSignal },
|
||||
): Promise<number[]> => {
|
||||
if (!text.trim()) {
|
||||
return [];
|
||||
}
|
||||
if (isCohere) {
|
||||
return (await embedCohere([text], "search_query", optionsValue?.signal))[0] ?? [];
|
||||
}
|
||||
return embedSingle(text, optionsValue?.signal);
|
||||
};
|
||||
|
||||
const embedBatch = async (
|
||||
texts: string[],
|
||||
optionsLocal?: { signal?: AbortSignal },
|
||||
): Promise<number[][]> => {
|
||||
if (texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (isCohere) {
|
||||
return embedCohere(texts, "search_document", optionsLocal?.signal);
|
||||
}
|
||||
return Promise.all(
|
||||
texts.map((t) => (t.trim() ? embedSingle(t, optionsLocal?.signal) : Promise.resolve([]))),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
provider: {
|
||||
id: "bedrock",
|
||||
model: client.model,
|
||||
maxInputTokens: spec?.maxTokens,
|
||||
embedQuery,
|
||||
embedBatch,
|
||||
},
|
||||
client,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveBedrockEmbeddingClient(
|
||||
options: MemoryEmbeddingProviderCreateOptions,
|
||||
): BedrockEmbeddingClient {
|
||||
const model = normalizeBedrockEmbeddingModel(options.model);
|
||||
const spec = resolveSpec(model);
|
||||
const providerConfig = options.config.models?.providers?.["amazon-bedrock"];
|
||||
|
||||
const region =
|
||||
regionFromUrl(options.remote?.baseUrl) ??
|
||||
regionFromUrl(providerConfig?.baseUrl) ??
|
||||
process.env.AWS_REGION ??
|
||||
process.env.AWS_DEFAULT_REGION ??
|
||||
"us-east-1";
|
||||
|
||||
let dimensions: number | undefined;
|
||||
if (options.outputDimensionality != null) {
|
||||
if (spec?.validDims && !spec.validDims.includes(options.outputDimensionality)) {
|
||||
throw new Error(
|
||||
`Invalid dimensions ${options.outputDimensionality} for ${model}. Valid values: ${spec.validDims.join(", ")}`,
|
||||
);
|
||||
}
|
||||
dimensions = options.outputDimensionality;
|
||||
} else {
|
||||
dimensions = spec?.dims;
|
||||
}
|
||||
|
||||
return { region, model, dimensions };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function hasAwsCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
loadCredentialProvider: AwsCredentialProviderLoader = loadCredentialProviderSdk,
|
||||
): Promise<boolean> {
|
||||
if (env.AWS_ACCESS_KEY_ID?.trim() && env.AWS_SECRET_ACCESS_KEY?.trim()) {
|
||||
return true;
|
||||
}
|
||||
if (env.AWS_BEARER_TOKEN_BEDROCK?.trim()) {
|
||||
return true;
|
||||
}
|
||||
const credentialProviderSdk = await loadCredentialProvider();
|
||||
if (!credentialProviderSdk) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const credentials = await credentialProviderSdk.defaultProvider({
|
||||
timeout: 1000,
|
||||
maxRetries: 0,
|
||||
})();
|
||||
return typeof credentials.accessKeyId === "string" && credentials.accessKeyId.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export { testing as __testing };
|
||||
1581
extensions/amazon-bedrock/index.test.ts
Normal file
1581
extensions/amazon-bedrock/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
15
extensions/amazon-bedrock/index.ts
Normal file
15
extensions/amazon-bedrock/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Amazon Bedrock provider plugin entry. Registers runtime streaming, discovery,
|
||||
* auth, thinking policy, guardrail, and memory embedding hooks.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerAmazonBedrockPlugin } from "./register.sync.runtime.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "amazon-bedrock",
|
||||
name: "Amazon Bedrock Provider",
|
||||
description: "Bundled Amazon Bedrock provider policy plugin",
|
||||
register(api) {
|
||||
registerAmazonBedrockPlugin(api);
|
||||
},
|
||||
});
|
||||
57
extensions/amazon-bedrock/lazy-import.test.ts
Normal file
57
extensions/amazon-bedrock/lazy-import.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Amazon Bedrock tests cover lazy import plugin behavior.
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
function mockBedrockSdkImportTripwire(): () => number {
|
||||
let importCount = 0;
|
||||
vi.doMock("@aws-sdk/client-bedrock", () => {
|
||||
importCount += 1;
|
||||
throw new Error("Bedrock SDK should not load during plugin registration");
|
||||
});
|
||||
return () => importCount;
|
||||
}
|
||||
|
||||
describe("amazon-bedrock lazy imports", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("@aws-sdk/client-bedrock");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("registers the runtime plugin without loading the Bedrock SDK", async () => {
|
||||
const getImportCount = mockBedrockSdkImportTripwire();
|
||||
const { default: amazonBedrockPlugin } = await import("./index.js");
|
||||
|
||||
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
|
||||
|
||||
expect(provider.id).toBe("amazon-bedrock");
|
||||
expect(provider.resolveConfigApiKey?.({ env: { AWS_PROFILE: "default" } } as never)).toBe(
|
||||
"AWS_PROFILE",
|
||||
);
|
||||
expect(getImportCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("registers the setup entry without loading the Bedrock SDK", async () => {
|
||||
const getImportCount = mockBedrockSdkImportTripwire();
|
||||
const { default: setupPlugin } = await import("./setup-api.js");
|
||||
const providers: Array<{
|
||||
id: string;
|
||||
resolveConfigApiKey?: (params: never) => string | undefined;
|
||||
}> = [];
|
||||
|
||||
setupPlugin.register({
|
||||
registerProvider(provider: {
|
||||
id: string;
|
||||
resolveConfigApiKey?: (params: never) => string | undefined;
|
||||
}) {
|
||||
providers.push(provider);
|
||||
},
|
||||
registerConfigMigration() {},
|
||||
} as never);
|
||||
|
||||
expect(providers.map((provider) => provider.id)).toEqual(["amazon-bedrock"]);
|
||||
expect(providers[0]?.resolveConfigApiKey?.({ env: { AWS_PROFILE: "default" } } as never)).toBe(
|
||||
"AWS_PROFILE",
|
||||
);
|
||||
expect(getImportCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
106
extensions/amazon-bedrock/memory-embedding-adapter.test.ts
Normal file
106
extensions/amazon-bedrock/memory-embedding-adapter.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// Amazon Bedrock tests cover memory embedding adapter plugin behavior.
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hasAwsCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
const createBedrockEmbeddingProviderMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./embedding-provider.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./embedding-provider.js")>();
|
||||
return {
|
||||
...actual,
|
||||
hasAwsCredentials: hasAwsCredentialsMock,
|
||||
createBedrockEmbeddingProvider: createBedrockEmbeddingProviderMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
|
||||
|
||||
function defaultCreateOptions() {
|
||||
return {
|
||||
config: {} as Record<string, unknown>,
|
||||
agentDir: "/tmp/test-agent",
|
||||
model: "",
|
||||
};
|
||||
}
|
||||
|
||||
function stubCreate(client: { region: string; model: string; dimensions?: number }) {
|
||||
createBedrockEmbeddingProviderMock.mockResolvedValue({
|
||||
provider: {
|
||||
id: "bedrock",
|
||||
model: client.model,
|
||||
embedQuery: async () => [],
|
||||
embedBatch: async () => [],
|
||||
},
|
||||
client,
|
||||
});
|
||||
}
|
||||
|
||||
describe("bedrockMemoryEmbeddingProviderAdapter", () => {
|
||||
beforeEach(() => {
|
||||
hasAwsCredentialsMock.mockReset();
|
||||
createBedrockEmbeddingProviderMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./embedding-provider.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("registers the expected adapter metadata", () => {
|
||||
expect(bedrockMemoryEmbeddingProviderAdapter.id).toBe("bedrock");
|
||||
expect(bedrockMemoryEmbeddingProviderAdapter.transport).toBe("remote");
|
||||
expect(bedrockMemoryEmbeddingProviderAdapter.authProviderId).toBe("amazon-bedrock");
|
||||
expect(bedrockMemoryEmbeddingProviderAdapter.autoSelectPriority).toBe(60);
|
||||
expect(bedrockMemoryEmbeddingProviderAdapter.allowExplicitWhenConfiguredAuto).toBe(true);
|
||||
});
|
||||
|
||||
it("throws a missing-api-key sentinel error when AWS credentials are unavailable", async () => {
|
||||
hasAwsCredentialsMock.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
|
||||
).rejects.toThrow(/No API key found for provider "bedrock"/);
|
||||
await expect(
|
||||
bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
|
||||
).rejects.toThrow(/AWS credentials are not available/);
|
||||
|
||||
expect(createBedrockEmbeddingProviderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates the provider when AWS credentials are available", async () => {
|
||||
hasAwsCredentialsMock.mockResolvedValue(true);
|
||||
stubCreate({ region: "us-east-1", model: "amazon.titan-embed-text-v2:0", dimensions: 1024 });
|
||||
|
||||
const result = await bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
||||
|
||||
expect(result.provider?.id).toBe("bedrock");
|
||||
expect(result.runtime).toEqual({
|
||||
id: "bedrock",
|
||||
cacheKeyData: {
|
||||
provider: "bedrock",
|
||||
region: "us-east-1",
|
||||
model: "amazon.titan-embed-text-v2:0",
|
||||
dimensions: 1024,
|
||||
},
|
||||
});
|
||||
expect(createBedrockEmbeddingProviderMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("lets the auto-select loop skip bedrock when credentials are unavailable", async () => {
|
||||
hasAwsCredentialsMock.mockResolvedValue(false);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect(bedrockMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection?.(thrown)).toBe(true);
|
||||
});
|
||||
});
|
||||
52
extensions/amazon-bedrock/memory-embedding-adapter.ts
Normal file
52
extensions/amazon-bedrock/memory-embedding-adapter.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Memory embedding adapter for Amazon Bedrock. It exposes Bedrock embeddings to
|
||||
* the memory-core engine and verifies AWS credentials before auto-selection.
|
||||
*/
|
||||
import {
|
||||
isMissingEmbeddingApiKeyError,
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
createBedrockEmbeddingProvider,
|
||||
DEFAULT_BEDROCK_EMBEDDING_MODEL,
|
||||
hasAwsCredentials,
|
||||
} from "./embedding-provider.js";
|
||||
|
||||
/** Memory-core adapter descriptor for Bedrock embeddings. */
|
||||
export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
|
||||
id: "bedrock",
|
||||
defaultModel: DEFAULT_BEDROCK_EMBEDDING_MODEL,
|
||||
transport: "remote",
|
||||
authProviderId: "amazon-bedrock",
|
||||
autoSelectPriority: 60,
|
||||
allowExplicitWhenConfiguredAuto: true,
|
||||
shouldContinueAutoSelection: isMissingEmbeddingApiKeyError,
|
||||
create: async (options) => {
|
||||
if (!(await hasAwsCredentials())) {
|
||||
throw new Error(
|
||||
'No API key found for provider "bedrock". ' +
|
||||
"AWS credentials are not available. " +
|
||||
"Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, AWS_PROFILE, or AWS_BEARER_TOKEN_BEDROCK, " +
|
||||
"configure an EC2/ECS/EKS role, " +
|
||||
"or set agents.defaults.memorySearch.provider to another provider.",
|
||||
);
|
||||
}
|
||||
const { provider, client } = await createBedrockEmbeddingProvider({
|
||||
...options,
|
||||
provider: "bedrock",
|
||||
fallback: "none",
|
||||
});
|
||||
return {
|
||||
provider,
|
||||
runtime: {
|
||||
id: "bedrock",
|
||||
cacheKeyData: {
|
||||
provider: "bedrock",
|
||||
region: client.region,
|
||||
model: client.model,
|
||||
dimensions: client.dimensions,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
470
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
Normal file
470
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,470 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1078.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1078.0",
|
||||
"@aws-sdk/credential-provider-node": "3.972.61",
|
||||
"@smithy/node-http-handler": "4.9.2",
|
||||
"@smithy/shared-ini-file-loader": "4.6.5",
|
||||
"@smithy/types": "4.15.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-bedrock": {
|
||||
"version": "3.1078.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1078.0.tgz",
|
||||
"integrity": "sha512-9nTsfK1iQFsDKJYuQFHAKPWnyhSA3MYhSYSYJXQojAt+d34V+iidEaDzXztMtu7imjC3kjZZAvo0WMybWz0nUg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.61",
|
||||
"@aws-sdk/token-providers": "3.1078.0",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/node-http-handler": "^4.9.2",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-bedrock-runtime": {
|
||||
"version": "3.1078.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1078.0.tgz",
|
||||
"integrity": "sha512-GGIpsHOk+zMRQMgxd+5D7Kfhpe6qzyGP4shGzb7NwYqAEleCW9PgX5xXuVQEESkhGX5kqMkDPfT+gg6PN2gczg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.61",
|
||||
"@aws-sdk/eventstream-handler-node": "^3.972.25",
|
||||
"@aws-sdk/middleware-eventstream": "^3.972.21",
|
||||
"@aws-sdk/middleware-websocket": "^3.972.34",
|
||||
"@aws-sdk/token-providers": "3.1078.0",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/node-http-handler": "^4.9.2",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.974.27",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.27.tgz",
|
||||
"integrity": "sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@aws-sdk/xml-builder": "^3.972.33",
|
||||
"@aws/lambda-invoke-store": "^0.3.0",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/signature-v4": "^5.6.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"bowser": "^2.11.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.52",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.52.tgz",
|
||||
"integrity": "sha512-sxuaHZGHqOgKB8OdL3doXa1NJjqmO60FPfyTnYVKGjX9taRsIEGS9pd+2yALmo06hijZ8L94uSK0kfXZsRmVyA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.54",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.54.tgz",
|
||||
"integrity": "sha512-e6yz52nq3SpR1oPLcvfsDM7H7k2gIYk/NSn/rwsFqzGXEwr3g0mRMlPbLaKCPCGNZJMU/gZg6/64B3eSam+gBw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/node-http-handler": "^4.9.2",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.972.59",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.59.tgz",
|
||||
"integrity": "sha512-9Um/UpruN76AdpiLnvwChVkJJwJ9Vx9ykk/2AeLxxSCM/YYRD8Kkq2towUk9fZQLV7dd9ATlsi87U7hKs0z/iQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.52",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.54",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.58",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.52",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.58",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.58",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/credential-provider-imds": "^4.4.5",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.58",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.58.tgz",
|
||||
"integrity": "sha512-H3q96qF8/DJsPsXMVtMRqSWOc85K5O4zos32untdw+vE5vw0f3a6qJo1YqbND4BsEIKd4iZmzzVUq9kV4LjbHg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.61",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.61.tgz",
|
||||
"integrity": "sha512-2U2KHMRCt1dlZoLU3KZR5g5EL4b0h2HHw96SkaUBK7qvEXPZj5rGRO/3ZTeJmh37dIYQuCnA2273rZOQvmsiHw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.52",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.54",
|
||||
"@aws-sdk/credential-provider-ini": "^3.972.59",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.52",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.58",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.58",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/credential-provider-imds": "^4.4.5",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.52",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.52.tgz",
|
||||
"integrity": "sha512-Aff9Ebs42lz+Ep1wkS+Nlwh5S0eahakpyskPsuKGjiBJ6ExOjNtxbfKJTKovQtQNgJ7oG1BH6esJwGrbs7qgSA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.972.58",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.58.tgz",
|
||||
"integrity": "sha512-syloC58mXOacUqM2toPNfwd7X3jT+tWj0F/cN7qdW1FQyI0q41J0tPf6DIZ56BF0x82iS9j3ALP45MoBz79YuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/token-providers": "3.1078.0",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.58",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.58.tgz",
|
||||
"integrity": "sha512-pTBImKzcGK+pcMKjL0fAJbnYzzYd1c0UDc7BSIOGNQhF9Nuk66vWlIXfYTYyzNSs+w8Q/vfbbNDDU8zdrouwLg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/eventstream-handler-node": {
|
||||
"version": "3.972.25",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.25.tgz",
|
||||
"integrity": "sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-eventstream": {
|
||||
"version": "3.972.21",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.21.tgz",
|
||||
"integrity": "sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-websocket": {
|
||||
"version": "3.972.34",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.34.tgz",
|
||||
"integrity": "sha512-8dxKLu5bC74SLwwoYV8RIiCD48jMbMt1Ccl3m+xtQJKet6QsZ4xzJlK6UDg7QNEzm/ZCUknJfGsBHmhkgOfuIQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/signature-v4": "^5.6.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.26",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.26.tgz",
|
||||
"integrity": "sha512-Lwe3F6K7bs+jEubp1LbrvzeMBYb5fMazJ1IxV9TtKWPF8CSh67Fmwyq9fLz3NL/k55Dfpuph5Dimw76JFgr+SA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/node-http-handler": "^4.9.2",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.38",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz",
|
||||
"integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/signature-v4": "^5.6.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1078.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz",
|
||||
"integrity": "sha512-/uyXLBGu3Lw1GbBA2X66hcOMnKtMcqAIF+3/eHfxBQmUeXF2sdqozDPrTfEr/TnSd0D6deZar+eVyhEqqWu29w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.26",
|
||||
"@aws-sdk/nested-clients": "^3.997.26",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.973.15",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz",
|
||||
"integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.33",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz",
|
||||
"integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.0.tgz",
|
||||
"integrity": "sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/credential-provider-imds": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.5.tgz",
|
||||
"integrity": "sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/fetch-http-handler": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.2.tgz",
|
||||
"integrity": "sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.2.tgz",
|
||||
"integrity": "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/shared-ini-file-loader": {
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.6.5.tgz",
|
||||
"integrity": "sha512-+X0fxlxHtALV4tBI4b/NZu7pLUh5AfHvCurvWn+Sdm+X7SCm+iWDOBu7ZwqNRI0BdfObkTWzFjUVnHheJaBrpA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/signature-v4": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.1.tgz",
|
||||
"integrity": "sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz",
|
||||
"integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
82
extensions/amazon-bedrock/openclaw.plugin.json
Normal file
82
extensions/amazon-bedrock/openclaw.plugin.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"id": "amazon-bedrock",
|
||||
"name": "Amazon Bedrock",
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["amazon-bedrock"],
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["bedrock"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"discovery": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"region": { "type": "string" },
|
||||
"providerFilter": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"refreshInterval": { "type": "integer", "minimum": 0 },
|
||||
"defaultContextWindow": { "type": "integer", "minimum": 1 },
|
||||
"defaultMaxTokens": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"guardrail": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"guardrailIdentifier": { "type": "string" },
|
||||
"guardrailVersion": { "type": "string" },
|
||||
"streamProcessingMode": { "type": "string", "enum": ["sync", "async"] },
|
||||
"trace": { "type": "string", "enum": ["enabled", "disabled", "enabled_full"] }
|
||||
},
|
||||
"required": ["guardrailIdentifier", "guardrailVersion"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityMigrationPaths": ["models.bedrockDiscovery"]
|
||||
},
|
||||
"uiHints": {
|
||||
"discovery": {
|
||||
"label": "Model Discovery",
|
||||
"help": "Plugin-owned controls for Amazon Bedrock model auto-discovery."
|
||||
},
|
||||
"discovery.enabled": {
|
||||
"label": "Enable Discovery",
|
||||
"help": "When false, OpenClaw keeps the Amazon Bedrock plugin available but skips implicit startup discovery. When true, discovery can run even without AWS auth env markers."
|
||||
},
|
||||
"discovery.region": {
|
||||
"label": "Discovery Region",
|
||||
"help": "AWS region to use for Bedrock model discovery. Defaults to AWS_REGION, AWS_DEFAULT_REGION, then us-east-1."
|
||||
},
|
||||
"discovery.providerFilter": {
|
||||
"label": "Provider Filter",
|
||||
"help": "Optional Bedrock provider-name allowlist for discovery, such as anthropic or amazon."
|
||||
},
|
||||
"discovery.refreshInterval": {
|
||||
"label": "Discovery Refresh Interval (s)",
|
||||
"help": "How long to cache Bedrock discovery results in seconds. Set to 0 to disable caching."
|
||||
},
|
||||
"discovery.defaultContextWindow": {
|
||||
"label": "Default Context Window",
|
||||
"help": "Fallback context window to assign to discovered Bedrock models."
|
||||
},
|
||||
"discovery.defaultMaxTokens": {
|
||||
"label": "Default Max Tokens",
|
||||
"help": "Fallback max output tokens to assign to discovered Bedrock models."
|
||||
},
|
||||
"guardrail": {
|
||||
"label": "Guardrail",
|
||||
"help": "Amazon Bedrock Guardrails settings applied to Bedrock model invocations."
|
||||
}
|
||||
}
|
||||
}
|
||||
42
extensions/amazon-bedrock/package.json
Normal file
42
extensions/amazon-bedrock/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1078.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1078.0",
|
||||
"@aws-sdk/credential-provider-node": "3.972.61",
|
||||
"@smithy/node-http-handler": "4.9.2",
|
||||
"@smithy/shared-ini-file-loader": "4.6.5",
|
||||
"@smithy/types": "4.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/amazon-bedrock-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
101
extensions/amazon-bedrock/provider-policy-api.test.ts
Normal file
101
extensions/amazon-bedrock/provider-policy-api.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Amazon Bedrock tests cover provider policy api plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveThinkingProfile } from "./provider-policy-api.js";
|
||||
|
||||
describe("amazon-bedrock provider-policy-api", () => {
|
||||
it("exposes adaptive thinking for Bedrock Claude 4.6 before runtime registration", () => {
|
||||
const profile = resolveThinkingProfile({
|
||||
provider: "amazon-bedrock",
|
||||
modelId: "amazon-bedrock/global.anthropic.claude-opus-4-6-v1",
|
||||
});
|
||||
|
||||
expect(profile?.levels.map((level) => level.id)).toEqual([
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"adaptive",
|
||||
"max",
|
||||
]);
|
||||
expect(profile?.defaultLevel).toBe("adaptive");
|
||||
});
|
||||
|
||||
it("caps Bedrock Claude Sonnet 4.6 at high effort", () => {
|
||||
const profile = resolveThinkingProfile({
|
||||
provider: "amazon-bedrock",
|
||||
modelId: "amazon-bedrock/global.anthropic.claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(profile?.levels.map((level) => level.id)).toEqual([
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"adaptive",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves Bedrock Claude Opus 4.8 thinking off by default with max effort available", () => {
|
||||
const profile = resolveThinkingProfile({
|
||||
provider: "amazon-bedrock",
|
||||
modelId:
|
||||
"arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.anthropic.claude-opus-4-8",
|
||||
});
|
||||
|
||||
expect(profile?.levels.map((level) => level.id)).toEqual([
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"adaptive",
|
||||
"max",
|
||||
]);
|
||||
expect(profile?.defaultLevel).toBe("off");
|
||||
});
|
||||
|
||||
it("exposes max thinking for Bedrock Claude Opus 4.7 refs", () => {
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "amazon-bedrock",
|
||||
modelId:
|
||||
"arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.anthropic.claude-opus-4-7",
|
||||
})?.levels.map((level) => level.id),
|
||||
).toEqual(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
canonicalModelId: "claude-fable-5",
|
||||
defaultLevel: "high",
|
||||
preservesCatalogOptOut: true,
|
||||
},
|
||||
{
|
||||
canonicalModelId: "claude-opus-4-8",
|
||||
defaultLevel: "off",
|
||||
preservesCatalogOptOut: false,
|
||||
},
|
||||
])(
|
||||
"resolves $canonicalModelId deployment aliases from canonical metadata",
|
||||
({ canonicalModelId, defaultLevel, preservesCatalogOptOut }) => {
|
||||
const profile = resolveThinkingProfile({
|
||||
provider: "amazon-bedrock",
|
||||
modelId: "production-claude",
|
||||
params: { canonicalModelId },
|
||||
});
|
||||
|
||||
expect(profile?.defaultLevel).toBe(defaultLevel);
|
||||
expect(profile?.levels.map((level) => level.id)).toContain("max");
|
||||
expect(profile?.preserveWhenCatalogReasoningFalse === true).toBe(preservesCatalogOptOut);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores unrelated providers", () => {
|
||||
expect(
|
||||
resolveThinkingProfile({ provider: "anthropic", modelId: "claude-opus-4-6" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
18
extensions/amazon-bedrock/provider-policy-api.ts
Normal file
18
extensions/amazon-bedrock/provider-policy-api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Provider-policy API for Amazon Bedrock. Core asks this plugin for thinking
|
||||
* profiles without importing provider registration or streaming code.
|
||||
*/
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { resolveBedrockClaudeThinkingProfile } from "./thinking-policy.js";
|
||||
|
||||
/** Resolve the Bedrock thinking profile for a provider/model pair. */
|
||||
export function resolveThinkingProfile(params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) {
|
||||
if (normalizeProviderId(params.provider) !== "amazon-bedrock") {
|
||||
return null;
|
||||
}
|
||||
return resolveBedrockClaudeThinkingProfile(params.modelId, params.params);
|
||||
}
|
||||
718
extensions/amazon-bedrock/register.sync.runtime.ts
Normal file
718
extensions/amazon-bedrock/register.sync.runtime.ts
Normal file
@@ -0,0 +1,718 @@
|
||||
/**
|
||||
* Synchronous Amazon Bedrock provider registration. It wires Bedrock streaming,
|
||||
* model discovery, thinking policy, guardrails, and embedding integration.
|
||||
*/
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { registerApiProvider, streamSimple } from "openclaw/plugin-sdk/llm";
|
||||
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
ProviderNormalizeResolvedModelContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
ANTHROPIC_BY_MODEL_REPLAY_HOOKS,
|
||||
normalizeProviderId,
|
||||
resolveClaudeFable5ModelIdentity,
|
||||
resolveClaudeModelIdentity,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
|
||||
import { supportsBedrockPromptCaching } from "./bedrock-options.js";
|
||||
import { mergeImplicitBedrockProvider, resolveBedrockConfigApiKey } from "./discovery-shared.js";
|
||||
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
|
||||
import { streamBedrock, streamSimpleBedrock } from "./stream.runtime.js";
|
||||
import {
|
||||
isLatestAdaptiveBedrockModelRef,
|
||||
isOpus47OrNewerBedrockModelRef,
|
||||
resolveBedrockNativeThinkingLevelMap,
|
||||
resolveBedrockClaudeThinkingProfile,
|
||||
supportsBedrockNativeMaxEffort,
|
||||
} from "./thinking-policy.js";
|
||||
|
||||
type GuardrailConfig = {
|
||||
guardrailIdentifier: string;
|
||||
guardrailVersion: string;
|
||||
streamProcessingMode?: "sync" | "async";
|
||||
trace?: "enabled" | "disabled" | "enabled_full";
|
||||
};
|
||||
|
||||
type AmazonBedrockPluginConfig = {
|
||||
discovery?: {
|
||||
enabled?: boolean;
|
||||
region?: string;
|
||||
providerFilter?: string[];
|
||||
refreshInterval?: number;
|
||||
defaultContextWindow?: number;
|
||||
defaultMaxTokens?: number;
|
||||
};
|
||||
guardrail?: GuardrailConfig;
|
||||
};
|
||||
|
||||
function normalizeBedrockResolvedModel({ modelId, model }: ProviderNormalizeResolvedModelContext) {
|
||||
const thinkingLevelMap = resolveBedrockNativeThinkingLevelMap(modelId, model.params);
|
||||
if (!thinkingLevelMap) {
|
||||
return undefined;
|
||||
}
|
||||
const reasoning =
|
||||
model.reasoning ||
|
||||
resolveClaudeFable5ModelIdentity({ id: modelId, params: model.params }) !== undefined;
|
||||
const current = model.thinkingLevelMap;
|
||||
const currentEfforts = current as Record<string, string | null | undefined> | undefined;
|
||||
if (
|
||||
reasoning === model.reasoning &&
|
||||
Object.entries(thinkingLevelMap).every(([level, effort]) => currentEfforts?.[level] === effort)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
reasoning,
|
||||
thinkingLevelMap: { ...thinkingLevelMap, ...current },
|
||||
};
|
||||
}
|
||||
|
||||
const BEDROCK_SERVICE_TIER_VALUES = ["flex", "priority", "default", "reserved"] as const;
|
||||
type BedrockServiceTier = (typeof BEDROCK_SERVICE_TIER_VALUES)[number];
|
||||
|
||||
function isAnthropicBedrockModel(modelId: string): boolean {
|
||||
const normalized = modelId.trim().toLowerCase();
|
||||
if (normalized.includes("anthropic.claude") || normalized.includes("anthropic/claude")) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/^arn:aws(-cn|-us-gov)?:bedrock:/.test(normalized) &&
|
||||
normalized.includes(":application-inference-profile/")
|
||||
) {
|
||||
const profileId = normalized.split(":application-inference-profile/")[1] ?? "";
|
||||
return profileId.includes("claude");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createBedrockNoCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) =>
|
||||
underlying(model, context, {
|
||||
...options,
|
||||
cacheRetention: "none",
|
||||
});
|
||||
}
|
||||
|
||||
function isBedrockServiceTier(value: string): value is BedrockServiceTier {
|
||||
return BEDROCK_SERVICE_TIER_VALUES.some((tier) => tier === value);
|
||||
}
|
||||
|
||||
function resolveBedrockServiceTier(
|
||||
extraParams: Record<string, unknown> | undefined,
|
||||
warn: (message: string) => void,
|
||||
): BedrockServiceTier | undefined {
|
||||
const raw = extraParams?.serviceTier ?? extraParams?.service_tier;
|
||||
if (typeof raw !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (isBedrockServiceTier(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
warn(`ignoring invalid Bedrock service_tier param: ${raw}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createBedrockServiceTierWrapper(
|
||||
underlying: StreamFn,
|
||||
serviceTier: BedrockServiceTier,
|
||||
): StreamFn {
|
||||
return (model, context, options) => {
|
||||
if (model.api !== "bedrock-converse-stream") {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
|
||||
payloadObj.serviceTier ??= { type: serviceTier };
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createGuardrailWrapStreamFn(
|
||||
innerWrapStreamFn: (ctx: {
|
||||
modelId: string;
|
||||
model?: { params?: Record<string, unknown> };
|
||||
streamFn?: StreamFn;
|
||||
}) => StreamFn | null | undefined,
|
||||
guardrailConfig: GuardrailConfig,
|
||||
): (ctx: {
|
||||
modelId: string;
|
||||
model?: { params?: Record<string, unknown> };
|
||||
streamFn?: StreamFn;
|
||||
}) => StreamFn | null | undefined {
|
||||
return (ctx) => {
|
||||
const inner = innerWrapStreamFn(ctx);
|
||||
if (!inner) {
|
||||
return inner;
|
||||
}
|
||||
return (model, context, options) => {
|
||||
return streamWithPayloadPatch(inner, model, context, options, (payload) => {
|
||||
const gc: Record<string, unknown> = {
|
||||
guardrailIdentifier: guardrailConfig.guardrailIdentifier,
|
||||
guardrailVersion: guardrailConfig.guardrailVersion,
|
||||
};
|
||||
if (guardrailConfig.streamProcessingMode) {
|
||||
gc.streamProcessingMode = guardrailConfig.streamProcessingMode;
|
||||
}
|
||||
if (guardrailConfig.trace) {
|
||||
gc.trace = guardrailConfig.trace;
|
||||
}
|
||||
payload.guardrailConfig = gc;
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function sharedRuntimeWouldInjectCachePoints(modelId: string): boolean {
|
||||
return supportsBedrockPromptCaching(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Bedrock application inference profile ARNs — these are the only IDs
|
||||
* where model-name-based checks fail because the ARN is opaque.
|
||||
* System-defined profiles (us., eu., global.) and base model IDs always
|
||||
* contain the model name and are handled by the shared model runtime natively.
|
||||
*/
|
||||
const BEDROCK_APP_INFERENCE_PROFILE_RE =
|
||||
/^arn:aws(-cn|-us-gov)?:bedrock:.*:application-inference-profile\//i;
|
||||
|
||||
function isBedrockAppInferenceProfile(modelId: string): boolean {
|
||||
return BEDROCK_APP_INFERENCE_PROFILE_RE.test(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared runtime's `supportsPromptCaching` checks `model.id` for specific Claude
|
||||
* model name patterns, which fails for application inference profile ARNs (opaque
|
||||
* IDs that may not contain the model name). When OpenClaw's `isAnthropicBedrockModel`
|
||||
* identifies the model but the shared runtime won't inject cache points, we do it via onPayload.
|
||||
*
|
||||
* Gated to application inference profile ARNs only — regular Claude model IDs and
|
||||
* system-defined inference profiles (us.anthropic.claude-*) are left to the shared runtime.
|
||||
*/
|
||||
function needsCachePointInjection(modelId: string): boolean {
|
||||
// Only target application inference profile ARNs.
|
||||
if (!isBedrockAppInferenceProfile(modelId)) {
|
||||
return false;
|
||||
}
|
||||
// If the shared runtime would already inject cache points, skip.
|
||||
if (sharedRuntimeWouldInjectCachePoints(modelId)) {
|
||||
return false;
|
||||
}
|
||||
// Check if OpenClaw identifies this as an Anthropic model via the ARN heuristic.
|
||||
if (isAnthropicBedrockModel(modelId)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the region from a Bedrock ARN.
|
||||
* e.g. "arn:aws:bedrock:us-east-1:123:application-inference-profile/abc" → "us-east-1"
|
||||
*/
|
||||
function extractRegionFromArn(arn: string): string | undefined {
|
||||
const parts = arn.split(":");
|
||||
// ARN format: arn:partition:service:region:account:resource
|
||||
return parts.length >= 4 && parts[3] ? parts[3] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a resolved foundation model ARN supports prompt caching using the
|
||||
* same matcher OpenClaw uses for direct model IDs.
|
||||
*/
|
||||
function resolvedModelSupportsCaching(modelArn: string): boolean {
|
||||
return supportsBedrockPromptCaching(modelArn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the underlying foundation model for an application inference profile
|
||||
* via GetInferenceProfile. Results are cached so we only call the API once per
|
||||
* profile ARN. Returns traits needed for request shaping when the model id is
|
||||
* otherwise opaque.
|
||||
*
|
||||
* Region is extracted from the profile ARN itself to avoid mismatches when
|
||||
* the OpenClaw config region differs from the profile's home region.
|
||||
*/
|
||||
type BedrockAppProfileTraits = {
|
||||
cacheEligible: boolean;
|
||||
omitTemperature: boolean;
|
||||
};
|
||||
|
||||
const appProfileTraitsCache = new Map<string, BedrockAppProfileTraits>();
|
||||
|
||||
type BedrockGetInferenceProfileResponse = {
|
||||
models?: Array<{ modelArn?: string }>;
|
||||
};
|
||||
|
||||
type BedrockControlPlane = {
|
||||
getInferenceProfile: (input: {
|
||||
inferenceProfileIdentifier: string;
|
||||
}) => Promise<BedrockGetInferenceProfileResponse>;
|
||||
};
|
||||
|
||||
async function createBedrockControlPlane(region: string | undefined): Promise<BedrockControlPlane> {
|
||||
await refreshAwsSharedConfigCacheForBedrock();
|
||||
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
|
||||
const client = new BedrockClient(region ? { region } : {});
|
||||
return {
|
||||
getInferenceProfile: async (input) => await client.send(new GetInferenceProfileCommand(input)),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAppProfileTraits(
|
||||
modelId: string,
|
||||
fallbackRegion: string | undefined,
|
||||
): Promise<BedrockAppProfileTraits> {
|
||||
const cached = appProfileTraitsCache.get(modelId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const region = extractRegionFromArn(modelId) ?? fallbackRegion;
|
||||
const controlPlane = await createBedrockControlPlane(region);
|
||||
const resp = await controlPlane.getInferenceProfile({ inferenceProfileIdentifier: modelId });
|
||||
const models = resp.models ?? [];
|
||||
const modelArns = models.map((m: { modelArn?: string }) => m.modelArn ?? "");
|
||||
const traits = {
|
||||
cacheEligible:
|
||||
models.length > 0 && modelArns.every((modelArn) => resolvedModelSupportsCaching(modelArn)),
|
||||
omitTemperature: modelArns.some(isOpus47OrNewerBedrockModelRef),
|
||||
};
|
||||
appProfileTraitsCache.set(modelId, traits);
|
||||
return traits;
|
||||
} catch {
|
||||
// Transient failures (throttling, network, IAM) should not be cached —
|
||||
// return the heuristic fallback but allow retry on the next request.
|
||||
return {
|
||||
cacheEligible: isAnthropicBedrockModel(modelId),
|
||||
omitTemperature: isOpus47OrNewerBedrockModelRef(modelId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type BedrockCachePoint = { cachePoint: { type: "default"; ttl?: string } };
|
||||
type BedrockContentBlock = Record<string, unknown>;
|
||||
type BedrockMessage = { role?: string; content?: BedrockContentBlock[] };
|
||||
|
||||
function hasCachePoint(blocks: BedrockContentBlock[] | undefined): boolean {
|
||||
return blocks?.some((b) => b.cachePoint != null) === true;
|
||||
}
|
||||
|
||||
function makeCachePoint(cacheRetention: string | undefined): BedrockCachePoint {
|
||||
return {
|
||||
cachePoint: {
|
||||
type: "default",
|
||||
...(cacheRetention === "long" ? { ttl: "1h" } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject Bedrock Converse cache points into the payload when the shared runtime skipped them
|
||||
* because it didn't recognize the model ID (application inference profiles).
|
||||
*/
|
||||
function injectBedrockCachePoints(
|
||||
payload: Record<string, unknown>,
|
||||
cacheRetention: string | undefined,
|
||||
): void {
|
||||
if (!cacheRetention || cacheRetention === "none") {
|
||||
return;
|
||||
}
|
||||
const point = makeCachePoint(cacheRetention);
|
||||
|
||||
// Inject into system prompt if missing.
|
||||
const system = payload.system as BedrockContentBlock[] | undefined;
|
||||
if (Array.isArray(system) && system.length > 0 && !hasCachePoint(system)) {
|
||||
system.push(point);
|
||||
}
|
||||
|
||||
// Inject into the last user message if missing.
|
||||
// Bedrock Converse uses lowercase roles ("user" / "assistant").
|
||||
const messages = payload.messages as BedrockMessage[] | undefined;
|
||||
if (Array.isArray(messages) && messages.length > 0) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
if (!hasCachePoint(msg.content)) {
|
||||
msg.content.push(point);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function patchMaxThinkingEffort(payload: Record<string, unknown>): void {
|
||||
const fieldsValue = payload.additionalModelRequestFields;
|
||||
const fields =
|
||||
fieldsValue && typeof fieldsValue === "object" && !Array.isArray(fieldsValue)
|
||||
? (fieldsValue as Record<string, unknown>)
|
||||
: {};
|
||||
const outputConfigValue = fields.output_config;
|
||||
const outputConfig =
|
||||
outputConfigValue && typeof outputConfigValue === "object" && !Array.isArray(outputConfigValue)
|
||||
? (outputConfigValue as Record<string, unknown>)
|
||||
: {};
|
||||
outputConfig.effort = "max";
|
||||
fields.output_config = outputConfig;
|
||||
payload.additionalModelRequestFields = fields;
|
||||
}
|
||||
|
||||
/** Register Amazon Bedrock provider, discovery catalog, stream wrappers, and embeddings. */
|
||||
export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
|
||||
// Keep registration-local constants inside the function so partial module
|
||||
// initialization during test bootstrap cannot trip TDZ reads.
|
||||
const providerId = "amazon-bedrock";
|
||||
// Match region from bedrock-runtime (Converse API) URLs.
|
||||
// e.g. https://bedrock-runtime.us-east-1.amazonaws.com
|
||||
const bedrockRegionRe = /bedrock-runtime\.([a-z0-9-]+)\.amazonaws\./;
|
||||
const bedrockContextOverflowPatterns = [
|
||||
/ValidationException.*(?:input is too long|max input token|input token.*exceed)/i,
|
||||
/ValidationException.*(?:exceeds? the (?:maximum|max) (?:number of )?(?:input )?tokens)/i,
|
||||
/ModelStreamErrorException.*(?:Input is too long|too many input tokens)/i,
|
||||
] as const;
|
||||
const deprecatedTemperatureValidationRe =
|
||||
/ValidationException[\s\S]*(?:invalid_request_error[\s\S]*)?temperature[\s\S]*deprecated|ValidationException[\s\S]*deprecated[\s\S]*temperature/i;
|
||||
const anthropicByModelReplayHooks = ANTHROPIC_BY_MODEL_REPLAY_HOOKS;
|
||||
const startupPluginConfig = (api.pluginConfig ?? {}) as AmazonBedrockPluginConfig;
|
||||
|
||||
registerApiProvider(
|
||||
{
|
||||
api: "bedrock-converse-stream",
|
||||
stream: streamBedrock,
|
||||
streamSimple: streamSimpleBedrock,
|
||||
},
|
||||
`plugin:${providerId}`,
|
||||
);
|
||||
|
||||
function resolveCurrentPluginConfig(
|
||||
config: OpenClawConfig | undefined,
|
||||
): AmazonBedrockPluginConfig | undefined {
|
||||
const runtimePluginConfig = resolvePluginConfigObject(config, providerId);
|
||||
return (
|
||||
(runtimePluginConfig as AmazonBedrockPluginConfig | undefined) ??
|
||||
(config ? undefined : startupPluginConfig)
|
||||
);
|
||||
}
|
||||
|
||||
api.registerMemoryEmbeddingProvider(bedrockMemoryEmbeddingProviderAdapter);
|
||||
|
||||
const baseWrapStreamFn = ({
|
||||
modelId,
|
||||
model,
|
||||
streamFn,
|
||||
}: {
|
||||
modelId: string;
|
||||
model?: { params?: Record<string, unknown> };
|
||||
streamFn?: StreamFn;
|
||||
}) => {
|
||||
const modelRef = { id: modelId, params: model?.params };
|
||||
if (
|
||||
isAnthropicBedrockModel(modelId) ||
|
||||
resolveClaudeModelIdentity(modelRef).startsWith("claude-")
|
||||
) {
|
||||
return streamFn;
|
||||
}
|
||||
// For app inference profiles with opaque IDs, don't force cacheRetention: "none"
|
||||
// yet — we may resolve them as Claude later via GetInferenceProfile.
|
||||
if (isBedrockAppInferenceProfile(modelId)) {
|
||||
return streamFn;
|
||||
}
|
||||
return createBedrockNoCacheWrapper(streamFn);
|
||||
};
|
||||
|
||||
function omitUnsupportedClaudeTemperature<TOptions extends object>(
|
||||
modelRef: { id: string; params?: Record<string, unknown> },
|
||||
options: TOptions,
|
||||
): TOptions {
|
||||
const canonicalModelId = resolveClaudeModelIdentity(modelRef);
|
||||
const omitsTemperature =
|
||||
isOpus47OrNewerBedrockModelRef(modelRef.id) ||
|
||||
isOpus47OrNewerBedrockModelRef(canonicalModelId) ||
|
||||
resolveClaudeFable5ModelIdentity(modelRef) !== undefined;
|
||||
if (!omitsTemperature || !("temperature" in options)) {
|
||||
return options;
|
||||
}
|
||||
const next = { ...options } as typeof options & { temperature?: unknown };
|
||||
delete next.temperature;
|
||||
return next;
|
||||
}
|
||||
|
||||
function omitUnsupportedClaudePayloadTemperature(payload: Record<string, unknown>): void {
|
||||
const inferenceConfig = payload.inferenceConfig;
|
||||
if (!inferenceConfig || typeof inferenceConfig !== "object") {
|
||||
return;
|
||||
}
|
||||
delete (inferenceConfig as Record<string, unknown>).temperature;
|
||||
}
|
||||
|
||||
function withAwsCredentialRefreshOnPayload<TOptions extends object>(
|
||||
options: TOptions,
|
||||
): TOptions & { onPayload: (payload: unknown, payloadModel: unknown) => Promise<unknown> } {
|
||||
const originalOnPayload = (options as { onPayload?: unknown }).onPayload as
|
||||
| ((payload: unknown, model: unknown) => unknown)
|
||||
| undefined;
|
||||
return {
|
||||
...options,
|
||||
onPayload: async (payload: unknown, payloadModel: unknown) => {
|
||||
await refreshAwsSharedConfigCacheForBedrock();
|
||||
return originalOnPayload?.(payload, payloadModel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAwsCredentialRefreshStreamWrapper(
|
||||
streamFn: StreamFn | null | undefined,
|
||||
): StreamFn | null | undefined {
|
||||
if (!streamFn) {
|
||||
return streamFn;
|
||||
}
|
||||
return (streamModel, context, options) =>
|
||||
streamFn(streamModel, context, withAwsCredentialRefreshOnPayload(Object.assign({}, options)));
|
||||
}
|
||||
|
||||
/** Extract the AWS region from a bedrock-runtime baseUrl. */
|
||||
function extractRegionFromBaseUrl(baseUrl: string | undefined): string | undefined {
|
||||
if (!baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
return bedrockRegionRe.exec(baseUrl)?.[1];
|
||||
}
|
||||
|
||||
/** Resolve the AWS region for Bedrock API calls from provider-specific baseUrl. */
|
||||
function resolveBedrockRegion(
|
||||
config: { models?: { providers?: Record<string, unknown> } } | undefined,
|
||||
): string | undefined {
|
||||
// Try provider-specific baseUrl first.
|
||||
const providers = config?.models?.providers;
|
||||
if (providers) {
|
||||
const exact = (providers[providerId] as { baseUrl?: string } | undefined)?.baseUrl;
|
||||
if (exact) {
|
||||
const region = extractRegionFromBaseUrl(exact);
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
// Fall back to alias matches (e.g. "bedrock" instead of "amazon-bedrock").
|
||||
for (const [key, value] of Object.entries(providers)) {
|
||||
if (key === providerId || normalizeProviderId(key) !== providerId) {
|
||||
continue;
|
||||
}
|
||||
const region = extractRegionFromBaseUrl((value as { baseUrl?: string }).baseUrl);
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
label: "Amazon Bedrock",
|
||||
docsPath: "/providers/models",
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const { resolveImplicitBedrockProvider } = await import("./discovery.js");
|
||||
const currentPluginConfig = resolveCurrentPluginConfig(ctx.config);
|
||||
const implicit = await resolveImplicitBedrockProvider({
|
||||
pluginConfig: currentPluginConfig,
|
||||
env: ctx.env,
|
||||
});
|
||||
if (!implicit) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: mergeImplicitBedrockProvider({
|
||||
existing: ctx.config.models?.providers?.[providerId],
|
||||
implicit,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env),
|
||||
normalizeResolvedModel: normalizeBedrockResolvedModel,
|
||||
...anthropicByModelReplayHooks,
|
||||
wrapStreamFn: ({ modelId, config, model, streamFn, thinkingLevel, extraParams }) => {
|
||||
const currentPluginConfig = resolveCurrentPluginConfig(config);
|
||||
const currentGuardrail = currentPluginConfig?.guardrail;
|
||||
const modelRef = { id: modelId, params: model?.params };
|
||||
const fable5 = resolveClaudeFable5ModelIdentity(modelRef) !== undefined;
|
||||
const canonicalModelId = resolveClaudeModelIdentity(modelRef);
|
||||
const opus47OrNewer =
|
||||
isOpus47OrNewerBedrockModelRef(modelId) || isOpus47OrNewerBedrockModelRef(canonicalModelId);
|
||||
const supportsNativeMax = supportsBedrockNativeMaxEffort(modelId, model?.params);
|
||||
let wrapped =
|
||||
(currentGuardrail?.guardrailIdentifier && currentGuardrail?.guardrailVersion
|
||||
? createGuardrailWrapStreamFn(
|
||||
baseWrapStreamFn,
|
||||
currentGuardrail,
|
||||
)({
|
||||
modelId,
|
||||
model,
|
||||
streamFn,
|
||||
})
|
||||
: baseWrapStreamFn({ modelId, model, streamFn })) ?? undefined;
|
||||
|
||||
const serviceTier = resolveBedrockServiceTier(extraParams, (message) =>
|
||||
api.logger.warn(message),
|
||||
);
|
||||
if (serviceTier && wrapped) {
|
||||
if (fable5 && serviceTier !== "default") {
|
||||
api.logger.warn(`ignoring unsupported Fable 5 Bedrock service tier: ${serviceTier}`);
|
||||
} else {
|
||||
wrapped = createBedrockServiceTierWrapper(wrapped, serviceTier);
|
||||
}
|
||||
}
|
||||
|
||||
const region =
|
||||
resolveBedrockRegion(config) ??
|
||||
extractRegionFromBaseUrl(model?.baseUrl) ??
|
||||
currentPluginConfig?.discovery?.region;
|
||||
const mayNeedCacheInjection =
|
||||
isBedrockAppInferenceProfile(modelId) && !sharedRuntimeWouldInjectCachePoints(modelId);
|
||||
const shouldOmitTemperature =
|
||||
opus47OrNewer || fable5 || isLatestAdaptiveBedrockModelRef(modelId, model?.params);
|
||||
const shouldPatchMaxThinking = supportsNativeMax && thinkingLevel === "max";
|
||||
const shouldPatchPayload = shouldOmitTemperature || shouldPatchMaxThinking;
|
||||
|
||||
// For known Anthropic models (heuristic match), enable injection immediately.
|
||||
// For opaque profile IDs, we'll resolve via GetInferenceProfile on first call.
|
||||
const heuristicMatch = needsCachePointInjection(modelId);
|
||||
|
||||
if (!region && !mayNeedCacheInjection && !shouldOmitTemperature && !shouldPatchMaxThinking) {
|
||||
return createAwsCredentialRefreshStreamWrapper(wrapped);
|
||||
}
|
||||
|
||||
const underlying = wrapped ?? streamFn;
|
||||
if (!underlying) {
|
||||
return wrapped;
|
||||
}
|
||||
return (streamModel, context, options) => {
|
||||
const merged = omitUnsupportedClaudeTemperature(
|
||||
modelRef,
|
||||
Object.assign({}, options, region ? { region } : {}),
|
||||
);
|
||||
|
||||
const originalOnPayload = merged.onPayload as
|
||||
| ((payload: unknown, model: unknown) => unknown)
|
||||
| undefined;
|
||||
|
||||
if (!mayNeedCacheInjection) {
|
||||
return underlying(
|
||||
streamModel,
|
||||
context,
|
||||
withAwsCredentialRefreshOnPayload({
|
||||
...merged,
|
||||
...(shouldPatchPayload
|
||||
? {
|
||||
onPayload: (payload: unknown, payloadModel: unknown) => {
|
||||
if (payload && typeof payload === "object") {
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
if (shouldPatchMaxThinking) {
|
||||
patchMaxThinkingEffort(payloadRecord);
|
||||
}
|
||||
if (shouldOmitTemperature) {
|
||||
omitUnsupportedClaudePayloadTemperature(payloadRecord);
|
||||
}
|
||||
}
|
||||
return originalOnPayload?.(payload, payloadModel);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Use the cacheRetention from options if explicitly set.
|
||||
// When undefined, default to "short" to match the shared runtime default.
|
||||
// Note: if the user set cacheRetention: "none" but the opaque ARN wasn't
|
||||
// recognized by resolveAnthropicCacheRetentionFamily, the value may have
|
||||
// been dropped upstream. This is a known limitation — the proper fix is
|
||||
// to also teach resolveAnthropicCacheRetentionFamily about opaque profiles
|
||||
// (tracked separately). In practice, users with app inference profiles
|
||||
// want caching enabled, so defaulting to "short" is the safer behavior.
|
||||
const cacheRetention =
|
||||
typeof merged.cacheRetention === "string" ? merged.cacheRetention : "short";
|
||||
if (heuristicMatch) {
|
||||
// Fast path: ARN heuristic already identified this as Claude, but the
|
||||
// concrete target may still need profile traits for Opus 4.7 payloads.
|
||||
const mayNeedTemperatureTrait = "temperature" in merged;
|
||||
return underlying(
|
||||
streamModel,
|
||||
context,
|
||||
withAwsCredentialRefreshOnPayload({
|
||||
...merged,
|
||||
onPayload: async (payload: unknown, payloadModel: unknown) => {
|
||||
if (payload && typeof payload === "object") {
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
injectBedrockCachePoints(payloadRecord, cacheRetention);
|
||||
if (shouldPatchMaxThinking) {
|
||||
patchMaxThinkingEffort(payloadRecord);
|
||||
}
|
||||
if (shouldOmitTemperature) {
|
||||
omitUnsupportedClaudePayloadTemperature(payloadRecord);
|
||||
} else if (mayNeedTemperatureTrait) {
|
||||
const traits = await resolveAppProfileTraits(modelId, region);
|
||||
if (traits.omitTemperature) {
|
||||
omitUnsupportedClaudePayloadTemperature(payloadRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
return originalOnPayload?.(payload, payloadModel);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Slow path: opaque profile ID — resolve underlying model via API (cached).
|
||||
// onPayload supports async, so we await the resolution inline.
|
||||
return underlying(
|
||||
streamModel,
|
||||
context,
|
||||
withAwsCredentialRefreshOnPayload({
|
||||
...merged,
|
||||
onPayload: async (payload: unknown, payloadModel: unknown) => {
|
||||
const traits = await resolveAppProfileTraits(modelId, region);
|
||||
if (payload && typeof payload === "object") {
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
if (traits.cacheEligible) {
|
||||
injectBedrockCachePoints(payloadRecord, cacheRetention);
|
||||
}
|
||||
if (shouldPatchMaxThinking) {
|
||||
patchMaxThinkingEffort(payloadRecord);
|
||||
}
|
||||
if (traits.omitTemperature) {
|
||||
omitUnsupportedClaudePayloadTemperature(payloadRecord);
|
||||
}
|
||||
}
|
||||
return originalOnPayload?.(payload, payloadModel);
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
},
|
||||
matchesContextOverflowError: ({ errorMessage }) =>
|
||||
bedrockContextOverflowPatterns.some((pattern) => pattern.test(errorMessage)),
|
||||
classifyFailoverReason: ({ errorMessage }) => {
|
||||
if (/ThrottlingException|Too many concurrent requests/i.test(errorMessage)) {
|
||||
return "rate_limit";
|
||||
}
|
||||
if (/ModelNotReadyException/i.test(errorMessage)) {
|
||||
return "overloaded";
|
||||
}
|
||||
if (deprecatedTemperatureValidationRe.test(errorMessage)) {
|
||||
return "format";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
resolveThinkingProfile: ({ modelId, params }) =>
|
||||
resolveBedrockClaudeThinkingProfile(modelId, params),
|
||||
});
|
||||
}
|
||||
22
extensions/amazon-bedrock/setup-api.ts
Normal file
22
extensions/amazon-bedrock/setup-api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Lightweight Amazon Bedrock setup entry. It exposes auth detection and config
|
||||
* migration hooks without loading runtime streaming or AWS discovery code.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { migrateAmazonBedrockLegacyConfig } from "./config-api.js";
|
||||
import { resolveBedrockConfigApiKey } from "./discovery-shared.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "amazon-bedrock",
|
||||
name: "Amazon Bedrock Setup",
|
||||
description: "Lightweight Amazon Bedrock setup hooks",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "amazon-bedrock",
|
||||
label: "Amazon Bedrock",
|
||||
auth: [],
|
||||
resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env),
|
||||
});
|
||||
api.registerConfigMigration((config) => migrateAmazonBedrockLegacyConfig(config));
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user