Compare commits

..

46 Commits

Author SHA1 Message Date
Dallin Romney
2e127090da feat(skills): install clawhub github sources 2026-06-01 15:00:22 -07:00
Dallin Romney
67e7450b90 fix(config): scope watcher warning to local gateway 2026-06-01 14:18:38 -07:00
Dallin Romney
4debab2d71 docs(memory): simplify watcher warning copy 2026-06-01 14:13:28 -07:00
Dallin Romney
beac3c9ccf docs(memory): clarify watcher default wording 2026-06-01 13:17:03 -07:00
Dallin Romney
d4cc83b607 fix(config): remove redundant warning boolean cast 2026-06-01 13:08:42 -07:00
Dallin Romney
6ec3f02401 fix(config): avoid warning helper narrowing 2026-06-01 12:59:56 -07:00
Dallin Romney
bada0a6609 fix(memory): warn on gateway watcher fd risk 2026-06-01 12:52:06 -07:00
Dallin Romney
624702efe7 fix(memory): default gateway memory watch off 2026-06-01 12:11:18 -07:00
Vincent Koc
66f797b22c fix(e2e): wait for plugin update registry cleanup 2026-06-01 21:01:26 +02:00
Vincent Koc
65a805ac28 fix(e2e): harden web search cleanup 2026-06-01 20:35:33 +02:00
Vincent Koc
b18bab0bcc refactor: share session kill http test fixtures 2026-06-01 20:35:08 +02:00
Alexzhu
9ac30b587e Keep machine-readable CLI startup output parseable (#88689)
Constraint: CLI startup progress can render before Commander resolves a command's JSON output contract.

Rejected: Leaving Clack on its default stdout | contaminates JSON stdout when startup progress appears.

Confidence: high

Scope-risk: narrow

Directive: Keep progress output off stdout before full command parsing for machine-readable invocations.

Tested: git diff --check origin/main; OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs src/cli/progress.test.ts src/cli/run-main.exit.test.ts; source CLI sessions --json parse proof.

Not-tested: broad pnpm check.
2026-06-01 11:33:22 -07:00
Peter Steinberger
82de264710 test(release): tolerate MiniMax portal nonce drift 2026-06-01 19:30:46 +01:00
Vincent Koc
7f7f0775ed fix(testing): keep crabbox sync checkouts durable 2026-06-01 20:30:08 +02:00
Vincent Koc
30819ed3da refactor: share http endpoint test scaffolding 2026-06-01 20:25:40 +02:00
Vincent Koc
1c3095e029 test(deps): clean dependency evidence temp roots 2026-06-01 20:20:42 +02:00
Vincent Koc
62cfc613f1 refactor: share startup early test inputs 2026-06-01 20:17:30 +02:00
Dallin Romney
64a946ac21 fix(agents): actionable copy for exhausted auth-profile failover (#85798)
* fix(agents): actionable copy for exhausted auth-profile failover

The pi-embedded runner threw a generic "No available auth profile for
<provider> (all in cooldown or unavailable)" message whenever every
configured profile was in cooldown, even though the failover machinery
had already resolved a concrete reason (auth, billing, rate_limit,
session_expired, etc.). The user-facing copy never used that reason and
never told the user how to recover.

Route the resolved reason through a single presenter
(`formatAuthProfileFailureMessage`) that composes a reason-specific
sentence with `buildProviderAuthRecoveryHint`, so FailoverError.message
ships with the right `openclaw models auth login --provider <id>` hint
when the cause is authentication/session/billing, and falls back to the
underlying provider error text otherwise. Helper moved out of
`src/commands/` into `src/agents/` because `src/agents/` cannot depend
on `src/commands/`.

* fix(agents): soften auth-profile failure copy for non-technical users

* refactor(agents): drop guidance re-export shim and de-brittle failure-copy tests

- Delete `src/commands/provider-auth-guidance.ts` and point doctor-auth, auth-choice.model-check, and models/list.status-command directly at `src/agents/provider-auth-recovery-hint.ts`. The cold-imports test moves with it.
- Rewrite `failure-copy.test.ts` to assert behavior (recovery-hint dispatch, provider mention, cause-suffix dedup) instead of pinning exact long copy strings, so wording tweaks no longer require a test update in two places.
2026-06-01 11:16:25 -07:00
Vincent Koc
96187089d4 refactor: share session history message fixtures 2026-06-01 20:05:18 +02:00
Vincent Koc
965e680603 test(control-ui): clean i18n timeout temp dirs 2026-06-01 20:03:05 +02:00
Vincent Koc
1cf39a2d6f refactor: table-drive lifecycle state tests 2026-06-01 19:57:35 +02:00
Vincent Koc
92b3d52e8a fix(e2e): isolate release media temp files 2026-06-01 19:56:05 +02:00
Dallin Romney
8ba6dfeaf6 fix(ci): restore dist cache before artifact builds (#89169) 2026-06-01 10:55:27 -07:00
Peter Steinberger
bddcf4448c fix(subagents): rotate steered restart sessions 2026-06-01 18:50:36 +01:00
Vincent Koc
c8a67768e3 fix(e2e): require expected web search rejection 2026-06-01 19:49:11 +02:00
Vincent Koc
26e61b2087 refactor: share single-row cache test helpers 2026-06-01 19:48:19 +02:00
Vincent Koc
ee48028028 fix(dev): clean tui pty watch children 2026-06-01 19:40:42 +02:00
Vincent Koc
3c324590ae refactor: share compaction checkpoint test helpers 2026-06-01 19:33:41 +02:00
Vincent Koc
ba88b7a178 fix(e2e): clean plugin lifecycle temp state 2026-06-01 19:27:04 +02:00
Vincent Koc
d767e296e2 refactor: share plugin node auth test helpers 2026-06-01 19:26:59 +02:00
Vincent Koc
83cd3cbe2a fix(e2e): bound bundled plugin lifecycle commands 2026-06-01 19:18:26 +02:00
Vincent Koc
16807824cc refactor: share node invoke approval test helpers 2026-06-01 19:18:14 +02:00
Dallin Romney
e3d24faecd fix: allow admins to approve dependency guard (#88966)
* fix: allow admins to approve dependency guard

* fix: auto-bypass trusted dependency authors
2026-06-01 10:17:14 -07:00
Peter Steinberger
469bec97ef test(codex): keep live subagent smoke lightweight 2026-06-01 18:09:48 +01:00
Vincent Koc
101db565ca refactor: share startup plugin test helpers 2026-06-01 19:09:39 +02:00
Vincent Koc
ef26e8dfce fix(repro): clean webchat tts proof artifacts 2026-06-01 19:04:12 +02:00
Vincent Koc
25c19e013a refactor: share startup memory test helpers 2026-06-01 19:00:26 +02:00
Vincent Koc
f2eea90dac fix(e2e): bound cron mcp probe waits 2026-06-01 18:52:13 +02:00
Vincent Koc
3113fe95ea refactor: share startup secrets test helpers 2026-06-01 18:49:58 +02:00
Vincent Koc
4e1f8b8ac7 fix(e2e): clean timed-out runtime commands 2026-06-01 18:43:25 +02:00
Vincent Koc
0b8f6b81e6 refactor: share probe request dispatch helper 2026-06-01 18:35:38 +02:00
Vincent Koc
ab1042d115 refactor: share talk transcription relay test setup 2026-06-01 18:34:05 +02:00
Peter Steinberger
9153aab037 fix(codex): abort app-server thread startup cleanly 2026-06-01 17:33:00 +01:00
Vincent Koc
285a792aa8 refactor: share maintenance test fixtures 2026-06-01 18:25:54 +02:00
Vincent Koc
a8bc1716dd fix(usage): skip empty timeseries scans 2026-06-01 18:20:52 +02:00
Vincent Koc
373ef81e83 refactor: share codex harness model assertions 2026-06-01 18:12:11 +02:00
656 changed files with 3929 additions and 8303 deletions

View File

@@ -605,7 +605,19 @@ jobs:
restore-keys: |
${{ runner.os }}-build-all-v3-
- name: Restore dist build cache
id: dist_build_cache
uses: actions/cache/restore@v5
with:
path: |
dist/
dist-runtime/
extensions/*/src/host/**/.bundle.hash
extensions/*/src/host/**/*.bundle.js
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
- name: Build dist
if: steps.dist_build_cache.outputs.cache-hit != 'true'
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build:ci-artifacts
@@ -614,14 +626,6 @@ jobs:
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
run: pnpm ui:i18n:check
- name: Cache dist build
uses: actions/cache@v5
with:
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
@@ -751,6 +755,18 @@ jobs:
done
exit "$failures"
- name: Save dist build cache
if: steps.dist_build_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
continue-on-error: true
with:
path: |
dist/
dist-runtime/
extensions/*/src/host/**/.bundle.hash
extensions/*/src/host/**/*.bundle.js
key: ${{ steps.dist_build_cache.outputs.cache-primary-key }}
- name: Upload gateway watch regression artifacts
if: always() && needs.preflight.outputs.run_check_additional == 'true'
uses: actions/upload-artifact@v7

View File

@@ -657,6 +657,7 @@ Docs: https://docs.openclaw.ai
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
- Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.
- TUI/streaming watchdog: dismiss the `This response is taking longer than expected` notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda.
- Agents/auth profiles: replace the bare `No available auth profile for <provider> (all in cooldown or unavailable)` TUI error with plain-language copy that explains what happened in user terms (sign-in expired, provider asking us to slow down, billing issue on the account, etc.) and suggests the matching `openclaw models auth login --provider <provider>` recovery command for sign-in and billing causes, while falling back to the underlying provider error for cases without a clear recovery path. Thanks @romneyda.
- Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.
## 2026.5.20

View File

@@ -85,6 +85,10 @@ OpenClaw indexes `MEMORY.md` and `memory/*.md` into chunks (~400 tokens with
- **Storage maintenance:** SQLite WAL sidecars are bounded with periodic and
shutdown checkpoints.
- **File watching:** changes to memory files trigger a debounced reindex (1.5s).
File watching is enabled by default, including for gateways, so memory edits
become searchable without a manual reindex. Large memory trees, `extraPaths`,
or QMD collections can use many file descriptors in long-lived gateways; set
`sync.watch: false` for affected agents if that becomes a problem.
- **Auto-reindex:** when the embedding provider, model, or chunking config
changes, the entire index is rebuilt automatically.
- **Reindex on demand:** `openclaw memory index --force`
@@ -125,8 +129,8 @@ openclaw memory index --force --agent main
Both standalone CLI commands and the Gateway use the same `local` provider id.
Set `memorySearch.provider: "local"` when you want local embeddings.
**Stale results?** Run `openclaw memory index --force` to rebuild. The watcher
may miss changes in rare edge cases.
**Stale results?** Run `openclaw memory index --force` to rebuild. Use this when
file watching is disabled or misses a change.
**sqlite-vec not loading?** OpenClaw falls back to in-process cosine similarity
automatically. `openclaw memory status --deep` reports the local vector store

View File

@@ -527,7 +527,9 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
</Accordion>
</AccordionGroup>
QMD boot refreshes use a one-shot subprocess path during gateway startup. The long-lived QMD manager still owns the regular file watcher and interval timers when memory search is opened for interactive use.
QMD boot refreshes use a one-shot subprocess path during gateway startup. The long-lived QMD manager owns the regular file watcher and interval timers when memory search is opened for interactive use.
Local Gateway configs can warn when memory file watching may keep too many files open. If you see open-file or watcher errors, set `sync.watch: false` for the affected agents and use manual indexing or `sync.intervalMinutes` to refresh memory.
### Full QMD example

View File

@@ -266,6 +266,7 @@ export async function startCodexAttemptThread(params: {
mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated,
environmentSelection: startupEnvironmentSelection,
contextEngineProjection: params.contextEngineProjection,
signal: params.signal,
pluginThreadConfig: pluginThreadConfigRequired
? {
enabled: true,

View File

@@ -169,6 +169,42 @@ function createTwoCalendarAppPolicyContext() {
setupRunAttemptTestHooks();
describe("Codex app-server thread lifecycle bindings", () => {
it("does not write a binding when thread start resolves after abort", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const abortController = new AbortController();
let resolveStart: ((value: ReturnType<typeof threadStartResult>) => void) | undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return await new Promise<ReturnType<typeof threadStartResult>>((resolve) => {
resolveStart = resolve;
});
}
throw new Error(`unexpected method: ${method}`);
});
const run = startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
signal: abortController.signal,
});
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("thread/start", expect.any(Object), {
signal: abortController.signal,
}),
);
abortController.abort("test_abort");
resolveStart?.(threadStartResult("thread-after-abort"));
await expect(run).rejects.toThrow("test_abort");
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
});
it("resumes a bound Codex thread when only dynamic tool descriptions change", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -243,6 +243,7 @@ export async function startOrResumeThread(params: {
environmentSelection?: CodexTurnEnvironmentParams[];
pluginThreadConfig?: CodexPluginThreadConfigProvider;
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
signal?: AbortSignal;
}): Promise<CodexAppServerThreadLifecycleBinding> {
// Thread lifecycle spans are useful when profiling startup churn, but normal
// turns should not pay Date.now/span-array overhead while resuming threads.
@@ -275,6 +276,22 @@ export async function startOrResumeThread(params: {
let preserveExistingBinding = false;
let rotatedContextEngineBinding = false;
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
const throwIfAborted = () => {
if (!params.signal?.aborted) {
return;
}
const reason = params.signal.reason;
if (reason instanceof Error) {
throw reason;
}
const error = new Error(
typeof reason === "string" && reason.length > 0
? reason
: "codex app-server thread lifecycle aborted",
);
error.name = "AbortError";
throw error;
};
if (binding?.threadId && params.nativeCodeModeEnabled === false) {
embeddedAgentLog.debug(
"codex app-server native tool surface disabled for turn; starting transient thread",
@@ -446,9 +463,10 @@ export async function startOrResumeThread(params: {
);
const response = assertCodexThreadResumeResponse(
await lifecycleTiming.measure("thread_resume_request", () =>
params.client.request("thread/resume", resumeParams),
params.client.request("thread/resume", resumeParams, { signal: params.signal }),
),
);
throwIfAborted();
const boundAuthProfileId = authProfileId;
const fallbackModelProvider = resolveCodexAppServerModelProvider({
provider: params.params.provider,
@@ -570,7 +588,7 @@ export async function startOrResumeThread(params: {
);
const threadStartResponse = await lifecycleTiming.measure("thread_start_request", async () => {
try {
return await params.client.request("thread/start", startParams);
return await params.client.request("thread/start", startParams, { signal: params.signal });
} catch (error) {
if (error instanceof CodexAppServerRpcError) {
throw new CodexThreadStartRequestError(error);
@@ -579,6 +597,7 @@ export async function startOrResumeThread(params: {
}
});
const response = assertCodexThreadStartResponse(threadStartResponse);
throwIfAborted();
const modelProvider = resolveCodexAppServerModelProvider({
provider: params.params.provider,
authProfileId: params.params.authProfileId,

View File

@@ -569,6 +569,41 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("logs qmd watcher errors without throwing", async () => {
cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } },
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
},
},
list: [{ id: agentId, default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 0, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "full" });
const watcher = watchMock.mock.results[0]?.value as {
emit: (event: string, ...args: unknown[]) => boolean;
};
expect(watcher.emit("error", new Error("watcher error: ENOSPC"))).toBe(true);
expect(logWarnMock).toHaveBeenCalledWith("qmd watcher error: watcher error: ENOSPC");
await manager.close();
});
it("delays qmd watch sync until changed file stats settle", async () => {
vi.useFakeTimers();
cfg = {

View File

@@ -1616,6 +1616,10 @@ export class QmdMemoryManager implements MemorySearchManager {
this.watcher.on("add", markDirty);
this.watcher.on("change", markDirty);
this.watcher.on("unlink", markDirty);
this.watcher.on("error", (err) => {
const message = err instanceof Error ? err.message : String(err);
log.warn(`qmd watcher error: ${message}`);
});
this.watcher.once("ready", () => {
log.info(
`qmd watcher ready for agent "${this.agentId}" paths=${watchPathList.length} durationMs=${Date.now() - startTime}`,

View File

@@ -28,18 +28,13 @@ import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { resolveConnectChallengeTimeoutMs, resolveSafeTimeoutDelayMs } from "./timeouts.js";
export type DeviceIdentity = {
/** Stable gateway device id associated with this keypair. */
deviceId: string;
/** PEM private key used by host deps to sign device-auth payloads. */
privateKeyPem: string;
/** PEM public key sent to the gateway during device pairing/auth. */
publicKeyPem: string;
};
export type DeviceAuthTokenRecord = {
/** Stored device bearer token returned by the gateway. */
token?: string;
/** Scopes granted to the stored token; reused only when still sufficient. */
scopes?: string[];
};
@@ -311,11 +306,8 @@ type Pending = {
};
export type GatewayClientRequestOptions = {
/** Wait for an accepted response followed by a final response. */
expectFinal?: boolean;
/** Per-request timeout; null disables request timeout scheduling. */
timeoutMs?: number | null;
/** Cancels the request and removes its pending response handler. */
signal?: AbortSignal;
/** Called once for expectFinal requests after an accepted response, before the final result. */
onAccepted?: (payload: unknown) => void;
@@ -363,15 +355,11 @@ const DEFAULT_GATEWAY_CLIENT_URL = "ws://127.0.0.1:18789";
const DEFAULT_CLIENT_VERSION = "0.0.0";
export type GatewayReconnectPausedInfo = {
/** WebSocket close code that paused reconnect attempts. */
code: number;
/** Raw close reason supplied by the gateway/socket. */
reason: string;
/** Structured connect-error detail code when the close came from gateway auth/startup. */
detailCode: string | null;
};
/** Error wrapper for gateway response frames that preserves retry metadata for callers. */
export class GatewayClientRequestError extends Error {
readonly gatewayCode: string;
readonly details?: unknown;
@@ -409,10 +397,8 @@ export function isGatewayConnectAssemblyError(value: unknown): value is Error {
);
}
/** Construction options for GatewayClient connections, auth, protocol bounds, and callbacks. */
export type GatewayClientOptions = {
url?: string; // ws://127.0.0.1:18789
/** Client-side watchdog for receiving the connect challenge. */
connectChallengeTimeoutMs?: number;
/** @deprecated Use connectChallengeTimeoutMs. */
connectDelayMs?: number;
@@ -464,7 +450,6 @@ export const GATEWAY_CLOSE_CODE_HINTS: Readonly<Record<number, string>> = {
1013: "try again later",
};
/** Returns the short operator-facing description for common gateway close codes. */
export function describeGatewayCloseCode(code: number): string | undefined {
return GATEWAY_CLOSE_CODE_HINTS[code];
}
@@ -505,8 +490,6 @@ export function resolveGatewayClientConnectChallengeTimeoutMs(
"connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs"
>,
): number {
// Keep the legacy connectDelayMs alias feeding the same clamp path until the
// public option is removed; explicit challenge timeout still wins.
return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts), {
configuredTimeoutMs: opts.preauthHandshakeTimeoutMs,
});

View File

@@ -1,7 +1,3 @@
/**
* Normalizes optional device metadata before it becomes part of a signed auth
* payload.
*/
export function normalizeDeviceMetadataForAuth(value?: string | null): string {
if (typeof value !== "string") {
return "";
@@ -10,38 +6,25 @@ export function normalizeDeviceMetadataForAuth(value?: string | null): string {
if (!trimmed) {
return "";
}
// Preserve the gateway's historical ASCII-only case fold; locale-sensitive
// lowercasing would change existing signatures for non-ASCII device names.
return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32));
}
type DeviceAuthPayloadParams = {
/** Stable device id paired with the gateway. */
deviceId: string;
/** Client application id, such as the desktop or mobile client. */
clientId: string;
/** Gateway client mode included in the signed payload. */
clientMode: string;
/** Requested gateway role for the authenticated device. */
role: string;
/** Ordered scope list; order is signature-significant. */
scopes: string[];
/** Signing timestamp in epoch milliseconds. */
signedAtMs: number;
/** Optional bootstrap token; null/undefined still reserves the v2/v3 field. */
token?: string | null;
/** Per-request nonce included to prevent replay. */
nonce: string;
};
type DeviceAuthPayloadV3Params = DeviceAuthPayloadParams & {
/** Optional normalized platform metadata appended after the v2 fields. */
platform?: string | null;
/** Optional normalized device-family metadata appended after platform. */
deviceFamily?: string | null;
};
/** Builds the canonical v2 device-auth string that the gateway verifies byte-for-byte. */
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const scopes = params.scopes.join(",");
const token = params.token ?? "";
@@ -58,7 +41,6 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string
].join("|");
}
/** Builds the canonical v3 device-auth string with normalized platform/family metadata. */
export function buildDeviceAuthPayloadV3(params: DeviceAuthPayloadV3Params): string {
const scopes = params.scopes.join(",");
const token = params.token ?? "";

View File

@@ -2,29 +2,19 @@ import { resolveFiniteTimeoutDelayMs } from "./timeouts.js";
/** Readiness probe outcome with timing data for diagnosing event-loop stalls. */
export type EventLoopReadyResult = {
/** True when enough consecutive timer checks stayed below the drift threshold. */
ready: boolean;
/** Wall-clock time spent in the readiness probe. */
elapsedMs: number;
/** Largest observed timer drift across all checks. */
maxDriftMs: number;
/** Number of scheduled timer checks that fired before completion. */
checks: number;
/** True when the supplied AbortSignal stopped the probe before readiness or timeout. */
aborted: boolean;
};
/** Controls how aggressively the client waits for low-drift timer checks before starting IO. */
export type EventLoopReadyOptions = {
/** Maximum wall-clock time to wait before reporting not ready. */
maxWaitMs?: number;
/** Delay between drift samples; clamped to safe Node timer bounds. */
intervalMs?: number;
/** Maximum acceptable timer drift for a sample to count as ready. */
driftThresholdMs?: number;
/** Number of low-drift samples required before the event loop is considered ready. */
consecutiveReadyChecks?: number;
/** Cancels the probe without starting client IO. */
signal?: AbortSignal;
};
@@ -114,8 +104,6 @@ export async function waitForEventLoopReady(
if (driftMs > driftThresholdMs) {
readyChecks = 0;
} else {
// Require consecutive low-drift samples so one lucky timer after a
// blocked loop does not start IO while the process is still saturated.
readyChecks += 1;
}
if (readyChecks >= consecutiveReadyChecks) {

View File

@@ -7,7 +7,6 @@ import {
import { resolveConnectChallengeTimeoutMs } from "./timeouts.js";
export type GatewayClientStartable = {
/** Starts the underlying gateway connection after readiness succeeds. */
start(): void;
};
@@ -18,14 +17,11 @@ export type EventLoopReadyWaiter = (
/** Timeout and abort controls for delaying client start until the loop can process IO. */
export type GatewayClientStartReadinessOptions = {
/** Explicit readiness wait cap; wins over client connection timeout settings. */
timeoutMs?: number;
/** Client connection settings used to derive a readiness cap when timeoutMs is absent. */
clientOptions?: Pick<
GatewayClientOptions,
"connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs"
>;
/** Cancels readiness without starting the client. */
signal?: AbortSignal;
};
@@ -37,8 +33,6 @@ function resolveGatewayClientStartReadinessTimeoutMs(
}
const clientOptions = options.clientOptions ?? {};
const timeoutOverride =
// Prefer the challenge watchdog over the older connectDelayMs alias so
// readiness stays aligned with the server-side preauth handshake window.
typeof clientOptions.connectChallengeTimeoutMs === "number" &&
Number.isFinite(clientOptions.connectChallengeTimeoutMs)
? clientOptions.connectChallengeTimeoutMs
@@ -61,8 +55,6 @@ export async function startGatewayClientWithReadinessWait(
maxWaitMs: resolveGatewayClientStartReadinessTimeoutMs(options),
signal: options.signal,
});
// The readiness waiter can race with abort delivery; gate start on both the
// returned state and the current signal so aborted startup remains side-effect-free.
if (readiness.ready && !readiness.aborted && options.signal?.aborted !== true) {
client.start();
}

View File

@@ -1,7 +1,5 @@
function parseStrictPositiveInteger(value: string): number | undefined {
const trimmed = value.trim();
// Env overrides accept only decimal integers so units/decimals do not
// silently truncate into a shorter timeout.
if (!/^\+?\d+$/u.test(trimmed)) {
return undefined;
}
@@ -21,8 +19,6 @@ export const MAX_CONNECT_CHALLENGE_TIMEOUT_MS = DEFAULT_PREAUTH_HANDSHAKE_TIMEOU
/** Clamps arbitrary timer delays to Node's safe range and an optional floor. */
export function resolveSafeTimeoutDelayMs(delayMs: number, opts?: { minMs?: number }): number {
const rawMinMs = opts?.minMs ?? 1;
// Clamp the floor first; callers can opt into immediate timers with minMs=0,
// but invalid floors still fall back to the nonzero default timeout guard.
const minMs = Math.min(
MAX_SAFE_TIMEOUT_DELAY_MS,
Math.max(0, Number.isFinite(rawMinMs) ? Math.floor(rawMinMs) : 1),
@@ -63,8 +59,6 @@ export function clampConnectChallengeTimeoutMs(
timeoutMs: number,
maxTimeoutMs = MAX_CONNECT_CHALLENGE_TIMEOUT_MS,
): number {
// Keep the upper bound at least as large as the watchdog floor so callers
// cannot invert the clamp range with an undersized configured server timeout.
return Math.max(
MIN_CONNECT_CHALLENGE_TIMEOUT_MS,
Math.min(Math.max(MIN_CONNECT_CHALLENGE_TIMEOUT_MS, maxTimeoutMs), timeoutMs),
@@ -111,8 +105,6 @@ export function resolveConnectChallengeTimeoutMs(
}
const envOverride = getConnectChallengeTimeoutMsFromEnv(params?.env);
if (envOverride !== undefined) {
// Explicit client overrides are allowed to exceed the server-derived cap
// for tests and slow environments; still apply the lower watchdog floor.
return clampConnectChallengeTimeoutMs(envOverride, Math.max(maxTimeoutMs, envOverride));
}
return clampConnectChallengeTimeoutMs(configuredPreauthTimeoutMs, maxTimeoutMs);

View File

@@ -1,10 +1,7 @@
import { scanFenceSpans, type FenceScanState, type FenceSpan } from "./fences.js";
/** Incremental inline-code scanner state carried between streamed chunks. */
export type InlineCodeState = {
/** True when a previous chunk opened a backtick run that has not closed yet. */
open: boolean;
/** Backtick run length required to close the current inline-code span. */
ticks: number;
};
@@ -24,7 +21,7 @@ type CodeSpanIndex = {
isInside: (index: number) => boolean;
};
/** Builds a zero-based code-region lookup for fenced and inline spans, plus next scanner state. */
/** Builds a lookup for fenced and inline code spans while preserving scanner state. */
export function buildCodeSpanIndex(
text: string,
inlineState?: InlineCodeState,
@@ -62,7 +59,6 @@ function parseInlineCodeSpans(
while (i < text.length) {
const fence = findFenceSpanAtInclusive(fenceSpans, i);
if (fence) {
// Fenced code owns its full range; inline backticks inside it must not change state.
i = fence.end;
continue;
}
@@ -95,7 +91,6 @@ function parseInlineCodeSpans(
}
if (open) {
// Treat an unfinished span as code through chunk end so partial tags stay protected.
spans.push([openStart, text.length]);
}

View File

@@ -28,7 +28,6 @@ type MarkdownToken = {
level?: number;
};
/** Style categories tracked as ranges over rendered plaintext. */
export type MarkdownStyle =
| "bold"
| "italic"
@@ -38,23 +37,19 @@ export type MarkdownStyle =
| "spoiler"
| "blockquote";
/** Half-open style range in `MarkdownIR.text`; `end` is exclusive. */
export type MarkdownStyleSpan = {
start: number;
end: number;
style: MarkdownStyle;
/** Fence language info for code blocks when markdown-it provided one. */
language?: string;
};
/** Half-open link-label range in `MarkdownIR.text` with the original href. */
export type MarkdownLinkSpan = {
start: number;
end: number;
href: string;
};
/** Plaintext markdown projection plus style/link ranges into that text. */
export type MarkdownIR = {
text: string;
styles: MarkdownStyleSpan[];
@@ -73,13 +68,11 @@ function createStyleSpan(params: MarkdownStyleSpan): MarkdownStyleSpan {
return span;
}
/** Parsed table text after markdown inline rendering has been applied per cell. */
export type MarkdownTableData = {
headers: string[];
rows: string[][];
};
/** Table metadata collected for block-mode rendering with the placeholder location. */
export type MarkdownTableMeta = MarkdownTableData & {
placeholderOffset: number;
};
@@ -123,15 +116,10 @@ type RenderState = RenderTarget & {
};
export type MarkdownParseOptions = {
/** Enable markdown-it linkify conversion. Default: true. */
linkify?: boolean;
/** Interpret paired `||` text delimiters as spoiler style spans. Default: false. */
enableSpoilers?: boolean;
/** Whether headings should become bold spans or plain text. Default: none. */
headingStyle?: "none" | "bold";
/** Text prefix inserted at each blockquote open before applying blockquote style. */
blockquotePrefix?: string;
/** Enable markdown-it autolinks. Default: true unless explicitly false. */
autolink?: boolean;
/** How to render tables (off|bullets|code|block). Default: off. */
tableMode?: MarkdownTableMode;
@@ -978,7 +966,6 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number):
return sliced;
}
/** Slices IR text and rebases overlapping style/link spans into the returned range. */
export function sliceMarkdownIR(ir: MarkdownIR, start: number, end: number): MarkdownIR {
return {
text: ir.text.slice(start, end),
@@ -987,12 +974,10 @@ export function sliceMarkdownIR(ir: MarkdownIR, start: number, end: number): Mar
};
}
/** Parses markdown into plaintext plus style/link ranges. */
export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR {
return markdownToIRWithMeta(markdown, options).ir;
}
/** Parses markdown into IR and returns table-detection metadata for table-aware callers. */
export function markdownToIRWithMeta(
markdown: string,
options: MarkdownParseOptions = {},
@@ -1055,7 +1040,6 @@ export function markdownToIRWithMeta(
};
}
/** Chunks IR text at readable boundaries and rebases style/link spans per chunk. */
export function chunkMarkdownIR(ir: MarkdownIR, limit: number): MarkdownIR[] {
if (!ir.text) {
return [];

View File

@@ -1,15 +1,12 @@
import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js";
/** Opening/closing marker pair used when rendering one Markdown style span. */
export type RenderStyleMarker = {
open: string | ((span: MarkdownStyleSpan) => string);
close: string;
};
/** Optional marker overrides keyed by Markdown style. */
export type RenderStyleMap = Partial<Record<MarkdownStyle, RenderStyleMarker>>;
/** Rendered link wrapper coordinates and markers returned by link builders. */
export type RenderLink = {
start: number;
end: number;
@@ -17,7 +14,6 @@ export type RenderLink = {
close: string;
};
/** Rendering hooks for escaping text, styles, and optional link wrappers. */
export type RenderOptions = {
styleMarkers: RenderStyleMap;
escapeText: (text: string) => string;
@@ -50,7 +46,6 @@ function sortStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
});
}
/** Renders Markdown IR by applying caller-provided style/link markers. */
export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions): string {
const text = ir.text ?? "";
if (!text) {
@@ -109,7 +104,7 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
}
const points = [...boundaries].toSorted((a, b) => a - b);
// Links and styles share one stack so overlapping spans close in one LIFO order.
// Unified stack for both styles and links, tracking close string and end position
const stack: { close: string; end: number }[] = [];
type OpeningItem =
| { end: number; open: string; close: string; kind: "link"; index: number }
@@ -126,7 +121,7 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
for (let i = 0; i < points.length; i += 1) {
const pos = points[i];
// Close every element ending here before opening new same-position spans.
// Close ALL elements (styles and links) in LIFO order at this position
while (stack.length && stack[stack.length - 1]?.end === pos) {
const item = stack.pop();
if (item) {

View File

@@ -10,12 +10,11 @@ const MARKDOWN_STYLE_MARKERS = {
code_block: { open: "```\n", close: "```" },
} as const;
/** Converts markdown tables into the configured plaintext/code mode while preserving links. */
/** Converts markdown tables into the configured plaintext/code rendering mode. */
export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string {
if (!markdown || mode === "off") {
return markdown;
}
// External "block" mode shares the code renderer when callers want inline replacement text.
const effectiveMode = mode === "block" ? "code" : mode;
const { ir, hasTables } = markdownToIRWithMeta(markdown, {
linkify: false,

View File

@@ -1,2 +1 @@
/** Table rendering modes shared by markdown parsing and table conversion helpers. */
export type MarkdownTableMode = "off" | "bullets" | "code" | "block";

View File

@@ -50,7 +50,6 @@ export function asSafeIntegerInRange(
return value;
}
/** Normalizes numeric string tokens while rejecting whitespace-only input. */
function normalizeNumericString(value: string): string | undefined {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
@@ -367,8 +366,6 @@ export function resolveExpiresAtMsFromDurationOrEpoch(
return resolveExpiresAtMsFromDurationSeconds(parsed, { nowMs: opts.nowMs });
}
const absoluteMillisecondsThreshold = opts.absoluteMillisecondsThreshold ?? 1_000_000_000_000;
// Values below this threshold are treated as epoch seconds; larger values are
// already millisecond timestamps and must fit JavaScript Date bounds.
if (parsed < absoluteMillisecondsThreshold) {
return resolveExpiresAtMsFromEpochSeconds(parsed);
}

View File

@@ -13,7 +13,7 @@ import {
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { delimiter, dirname, extname, isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolvePathEnvKey } from "./windows-cmd-helpers.mjs";
@@ -1540,7 +1540,9 @@ function isWindowsRemoteTarget(commandArgs) {
}
function isNativeWindowsRemoteTarget(commandArgs) {
return isWindowsRemoteTarget(commandArgs) && optionValue(commandArgs, "--windows-mode") !== "wsl2";
return (
isWindowsRemoteTarget(commandArgs) && optionValue(commandArgs, "--windows-mode") !== "wsl2"
);
}
function isAwsMacosRemoteTarget(commandArgs, providerName) {
@@ -1553,14 +1555,14 @@ function isAwsMacosRemoteTarget(commandArgs, providerName) {
function remoteWindowsHydratedNodeModulesBootstrap() {
return [
'$openclawModulesDir = $env:PNPM_CONFIG_MODULES_DIR',
'if ($openclawModulesDir) {',
"$openclawModulesDir = $env:PNPM_CONFIG_MODULES_DIR",
"if ($openclawModulesDir) {",
'if (-not (Test-Path $openclawModulesDir)) { throw "PNPM_CONFIG_MODULES_DIR does not exist: $openclawModulesDir" }',
'$openclawWorkspaceModules = Join-Path (Get-Location).Path "node_modules"',
'$openclawSelfModules = Join-Path $openclawModulesDir "node_modules"',
'if (-not (Test-Path $openclawSelfModules)) { cmd /c mklink /J "$openclawSelfModules" "$openclawModulesDir" | Out-Host; if ($LASTEXITCODE -ne 0) { throw "failed to link hydrated pnpm node_modules" } }',
'if (-not (Test-Path $openclawWorkspaceModules)) { cmd /c mklink /J "$openclawWorkspaceModules" "$openclawModulesDir" | Out-Host; if ($LASTEXITCODE -ne 0) { throw "failed to link workspace node_modules" } }',
'}',
"}",
].join("; ");
}
@@ -1894,8 +1896,23 @@ function shouldUseFullCheckoutForCleanRemoteSync(commandArgs, _providerName) {
return isSparseCheckout() || isChangedGateCommand(runCommandArgs(commandArgs));
}
function defaultFullCheckoutSyncRoot() {
const home = homedir();
if (home) {
return resolve(home, ".cache", "openclaw", "crabbox-sync");
}
return resolve(tmpdir(), "openclaw-crabbox-sync");
}
function fullCheckoutSyncRoot() {
const configured = process.env.OPENCLAW_CRABBOX_SYNC_TMPDIR?.trim();
const root = configured ? resolve(configured) : defaultFullCheckoutSyncRoot();
mkdirSync(root, { recursive: true });
return root;
}
function prepareFullCheckoutForSync(options = {}) {
const dir = mkdtempSync(resolve(tmpdir(), "openclaw-crabbox-sync-"));
const dir = mkdtempSync(resolve(fullCheckoutSyncRoot(), "openclaw-crabbox-sync-"));
let active = false;
const add = gitOutput(["worktree", "add", "--detach", dir, "HEAD"]);
if (add.status !== 0) {

View File

@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
type Options = {
altScreen: boolean;
@@ -20,6 +21,24 @@ const MODE_TEST_FILES = {
const MIRROR_TERMINAL_QUERIES = ["\x1b[?u", "\x1b[16t"];
const DEFAULT_PTY_COLS = 100;
const DEFAULT_PTY_ROWS = 30;
const CHILD_SIGTERM_GRACE_MS = 500;
const CHILD_SIGKILL_GRACE_MS = 5_000;
type KillableChild = {
pid?: number;
kill(signal: NodeJS.Signals): boolean;
};
type ChildStopper = {
cancel: () => void;
stop: () => void;
};
type SignalChild = (child: KillableChild, signal: NodeJS.Signals) => void;
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
(timer as { unref?: () => void }).unref?.();
}
function readOption(args: string[], name: string): string | undefined {
const idx = args.indexOf(name);
@@ -72,6 +91,64 @@ function currentTerminalDimension(value: number | undefined, fallback: number):
return String(value && value > 0 ? value : fallback);
}
function signalChildProcessTree(child: KillableChild, signal: NodeJS.Signals): void {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Non-detached fallback or already-exited group; direct child signaling is
// still useful on platforms without process groups.
}
}
child.kill(signal);
}
function createChildStopper(
child: KillableChild,
options: {
signalChild?: SignalChild;
sigtermGraceMs?: number;
sigkillGraceMs?: number;
} = {},
): ChildStopper {
const signalChild = options.signalChild ?? signalChildProcessTree;
const sigtermGraceMs = options.sigtermGraceMs ?? CHILD_SIGTERM_GRACE_MS;
const sigkillGraceMs = options.sigkillGraceMs ?? CHILD_SIGKILL_GRACE_MS;
let stopping = false;
let termTimer: ReturnType<typeof setTimeout> | undefined;
let killTimer: ReturnType<typeof setTimeout> | undefined;
const cancel = () => {
if (termTimer) {
clearTimeout(termTimer);
termTimer = undefined;
}
if (killTimer) {
clearTimeout(killTimer);
killTimer = undefined;
}
};
const stop = () => {
if (stopping) {
return;
}
stopping = true;
signalChild(child, "SIGINT");
termTimer = setTimeout(() => {
signalChild(child, "SIGTERM");
killTimer = setTimeout(() => {
signalChild(child, "SIGKILL");
}, sigkillGraceMs);
unrefTimer(killTimer);
}, sigtermGraceMs);
unrefTimer(termTimer);
};
return { cancel, stop };
}
async function createMirrorFile(mirrorPath: string): Promise<void> {
await mkdir(path.dirname(mirrorPath), { recursive: true });
await writeFile(mirrorPath, "", "utf8");
@@ -108,6 +185,7 @@ async function main(): Promise<void> {
],
{
cwd: process.cwd(),
detached: process.platform !== "win32",
env: {
...process.env,
OPENCLAW_TUI_PTY_MIRROR_PATH: options.mirrorPath,
@@ -172,10 +250,8 @@ async function main(): Promise<void> {
}
};
const stopChild = () => {
child.kill("SIGINT");
setTimeout(() => child.kill("SIGTERM"), 500).unref();
};
const childStopper = createChildStopper(child);
const stopChild = childStopper.stop;
const ignoredInput = (chunk: Buffer) => {
if (chunk.includes(0x03)) {
@@ -238,12 +314,20 @@ async function main(): Promise<void> {
childStderr += chunk.toString("utf8");
});
let childExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
child.on("exit", (code, signal) => {
childExit = { code, signal };
type ChildExit = { code: number | null; signal: NodeJS.Signals | null };
let childExit: ChildExit | null = null;
const childFinished = new Promise<ChildExit>((resolve) => {
child.once("exit", (code, signal) => {
childExit = { code, signal };
childStopper.cancel();
resolve(childExit);
});
});
process.once("SIGINT", stopChild);
const parentSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
for (const signal of parentSignals) {
process.once(signal, stopChild);
}
try {
for (;;) {
@@ -265,6 +349,12 @@ async function main(): Promise<void> {
writeMirrorChunk(result.chunk);
}
} finally {
if (!childExit) {
stopChild();
}
for (const signal of parentSignals) {
process.off(signal, stopChild);
}
await drainParentInput();
restoreInput();
if (useAltScreen) {
@@ -273,6 +363,10 @@ async function main(): Promise<void> {
restoreScreen();
}
if (!childExit) {
childExit = await childFinished;
}
if (childStdout) {
process.stdout.write(childStdout);
}
@@ -288,9 +382,16 @@ async function main(): Promise<void> {
}
}
main().catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.stack || error.message : String(error)}\n`,
);
process.exit(1);
});
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.stack || error.message : String(error)}\n`,
);
process.exit(1);
});
}
export const testing = {
createChildStopper,
signalChildProcessTree,
};

View File

@@ -16,6 +16,7 @@ for env_name in \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_COMMAND_TIMEOUT \
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_SMOKE \
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE \
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_OUTPUT_CHARS \

View File

@@ -4,15 +4,36 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { assert, connectGateway, type GatewayRpcClient, waitFor } from "./mcp-channels-harness.ts";
import type { GatewayRpcClient } from "./mcp-channels-harness.ts";
const execFileAsync = promisify(execFile);
const PROBE_PID_WAIT_MS = readPositiveInt(
process.env.OPENCLAW_CRON_MCP_CLEANUP_PID_WAIT_MS,
120_000,
);
type McpChannelsHarness = typeof import("./mcp-channels-harness.ts");
let mcpChannelsHarness: McpChannelsHarness | undefined;
type CronJob = { id?: string };
type CronRunResult = { ok?: boolean; enqueued?: boolean; runId?: string };
type AgentRunResult = { runId?: string; status?: string };
async function loadMcpChannelsHarness(): Promise<McpChannelsHarness> {
mcpChannelsHarness ??= await import("./mcp-channels-harness.ts");
return mcpChannelsHarness;
}
function readPositiveInt(raw: string | undefined, fallback: number): number {
const text = (raw ?? "").trim();
if (!/^\d+$/u.test(text)) {
return fallback;
}
const parsed = Number(text);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
async function readProbePid(pidPath: string): Promise<number | undefined> {
try {
const raw = (await fs.readFile(pidPath, "utf-8")).trim();
@@ -52,14 +73,19 @@ async function describeProbePid(pid: number): Promise<string | undefined> {
}
}
async function waitForProbePid(pidPath: string): Promise<number | undefined> {
export async function waitForProbePid(
pidPath: string,
options: { pollMs?: number; timeoutMs?: number } = {},
): Promise<number | undefined> {
const timeoutMs = options.timeoutMs ?? PROBE_PID_WAIT_MS;
const pollMs = options.pollMs ?? 100;
const startedAt = Date.now();
while (Date.now() - startedAt < 600_000) {
while (Date.now() - startedAt < timeoutMs) {
const pid = await readProbePid(pidPath);
if (pid) {
return pid;
}
await delay(100);
await delay(pollMs);
}
return undefined;
}
@@ -128,6 +154,7 @@ async function runCronCleanupScenario(params: {
gateway: GatewayRpcClient;
pidPath: string;
}): Promise<{ jobId: string; runId?: string; pid: number; status?: unknown }> {
const { assert, waitFor } = await loadMcpChannelsHarness();
const { gateway, pidPath } = params;
const job = await gateway.request<CronJob>("cron.add", {
name: "cron mcp cleanup docker e2e",
@@ -171,7 +198,7 @@ async function runCronCleanupScenario(params: {
const pid = await waitForProbePid(pidPath);
assert(
pid,
`cron MCP probe did not start; missing pid file at ${pidPath}; events=${JSON.stringify(
`cron MCP probe did not start within ${PROBE_PID_WAIT_MS}ms; missing pid file at ${pidPath}; events=${JSON.stringify(
gateway.events.slice(-10),
)}`,
);
@@ -209,6 +236,7 @@ async function runSubagentCleanupScenario(params: {
pidsPath: string;
exitPath: string;
}): Promise<{ runId: string; exitedPids: number[]; pids: number[] }> {
const { assert } = await loadMcpChannelsHarness();
const { gateway, pidPath, pidsPath, exitPath } = params;
await resetProbeFiles({ pidPath, pidsPath, exitPath });
@@ -258,6 +286,7 @@ async function runSubagentCleanupScenario(params: {
}
async function main() {
const { assert, connectGateway } = await loadMcpChannelsHarness();
const gatewayUrl = process.env.GW_URL?.trim();
const gatewayToken = process.env.GW_TOKEN?.trim();
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw");
@@ -283,4 +312,6 @@ async function main() {
}
}
await main();
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -56,9 +56,10 @@ const FORBIDDEN_POST_READY_DEPS_WORK = [/\b(?:npm|pnpm|yarn|corepack) install\b/
const PACKAGE_MANAGER_PROCESS_BASENAME = /^(?:npm|pnpm|yarn|corepack)(?:$|[.-])/u;
const PROCESS_SNAPSHOT_ARGS = ["-ww", "-eo", "pid=,ppid=,args="];
const isolatedStateRoots = new WeakMap();
const activeCommandChildren = new Set();
const activeGatewayChildren = new Set();
const parentSignalHandlers = new Map();
let gatewayExitCleanupInstalled = false;
let parentCleanupInstalled = false;
function readPositiveInt(raw, fallback) {
const text = String(raw ?? "").trim();
@@ -400,10 +401,15 @@ function createBoundedGatewayLog(logPath) {
export function runCommand(command, args, options = {}) {
return new Promise((resolve, reject) => {
const { timeoutMs = COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
const detached = spawnOptions.detached ?? process.platform !== "win32";
const child = childProcess.spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
...spawnOptions,
detached,
});
if (detached) {
trackCommandChild(child);
}
let stdout = { text: "", truncatedChars: 0 };
let stderr = { text: "", truncatedChars: 0 };
let timedOut = false;
@@ -417,7 +423,7 @@ export function runCommand(command, args, options = {}) {
const clearCommandTimer = timeoutMs
? setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
signalChildProcessTree(child, "SIGKILL");
}, timeoutMs)
: undefined;
child.on("error", (error) => {
@@ -508,14 +514,24 @@ function trackGatewayChild(child) {
};
child.once("error", untrack);
child.once("close", untrack);
installGatewayParentCleanup();
installParentCleanup();
}
function installGatewayParentCleanup() {
if (!gatewayExitCleanupInstalled) {
gatewayExitCleanupInstalled = true;
function trackCommandChild(child) {
activeCommandChildren.add(child);
const untrack = () => {
activeCommandChildren.delete(child);
};
child.once("error", untrack);
child.once("close", untrack);
installParentCleanup();
}
function installParentCleanup() {
if (!parentCleanupInstalled) {
parentCleanupInstalled = true;
process.once("exit", () => {
cleanupActiveGatewayChildren("SIGTERM");
cleanupActiveChildren("SIGTERM");
});
}
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
@@ -523,7 +539,7 @@ function installGatewayParentCleanup() {
continue;
}
const handler = () => {
cleanupActiveGatewayChildren(signal);
cleanupActiveChildren(signal);
for (const [registeredSignal, registeredHandler] of parentSignalHandlers) {
process.off(registeredSignal, registeredHandler);
}
@@ -535,11 +551,17 @@ function installGatewayParentCleanup() {
}
}
function cleanupActiveGatewayChildren(signal) {
for (const child of activeGatewayChildren) {
signalGateway(child, signal);
function cleanupActiveChildren(signal) {
for (const child of activeCommandChildren) {
signalChildProcessTree(child, signal);
if (process.platform !== "win32") {
signalGateway(child, "SIGKILL");
signalChildProcessTree(child, "SIGKILL");
}
}
for (const child of activeGatewayChildren) {
signalChildProcessTree(child, signal);
if (process.platform !== "win32") {
signalChildProcessTree(child, "SIGKILL");
}
}
}
@@ -559,11 +581,11 @@ export async function stopGateway(child) {
return !processTreeIsAlive(child);
};
signalGateway(child, "SIGTERM");
signalChildProcessTree(child, "SIGTERM");
if (await waitForExit(GATEWAY_TEARDOWN_GRACE_MS)) {
return;
}
signalGateway(child, "SIGKILL");
signalChildProcessTree(child, "SIGKILL");
await waitForExit(GATEWAY_TEARDOWN_KILL_GRACE_MS);
}
@@ -585,15 +607,14 @@ function processTreeIsAlive(child) {
}
}
function signalGateway(child, signal) {
function signalChildProcessTree(child, signal) {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch (error) {
if (error?.code === "ESRCH") {
return;
}
} catch {
// Non-detached callers may not own a process group keyed by child.pid; keep
// the legacy direct-child kill path as the fallback.
}
}
try {

View File

@@ -19,11 +19,30 @@ openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing
probe="scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs"
runtime_smoke="scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs"
node "$probe" select > /tmp/bundled-plugin-sweep-ids
sweep_command_timeout="${OPENCLAW_BUNDLED_PLUGIN_SWEEP_COMMAND_TIMEOUT:-300s}"
now_ms() {
node -e 'process.stdout.write(String(Date.now()))'
}
run_logged_sweep_command() {
local label="$1"
local log_file="$2"
shift 2
if openclaw_e2e_maybe_timeout "$sweep_command_timeout" "$@" >"$log_file" 2>&1; then
return 0
else
local status=$?
cat "$log_file"
if [ "$status" -eq 124 ]; then
echo "Bundled plugin sweep command timed out after $sweep_command_timeout: $label" >&2
else
echo "Bundled plugin sweep command failed with status $status: $label" >&2
fi
return "$status"
fi
}
lifecycle_trace_enabled() {
case "${OPENCLAW_PLUGIN_LIFECYCLE_TRACE:-}" in
1 | true | TRUE | yes | YES)
@@ -53,10 +72,8 @@ for plugin_entry in "${plugin_entries[@]}"; do
uninstall_log="/tmp/openclaw-uninstall-${plugin_index}.log"
plugin_started_at="$(now_ms)"
echo "Installing bundled plugin: $plugin_id ($plugin_dir)"
node "$OPENCLAW_ENTRY" plugins install "$plugin_id" >"$install_log" 2>&1 || {
cat "$install_log"
exit 1
}
run_logged_sweep_command "install $plugin_id" "$install_log" \
node "$OPENCLAW_ENTRY" plugins install "$plugin_id"
if lifecycle_trace_enabled; then
cat "$install_log"
fi
@@ -74,10 +91,8 @@ for plugin_entry in "${plugin_entries[@]}"; do
runtime_finished_at="$(now_ms)"
echo "Uninstalling bundled plugin: $plugin_id ($plugin_dir)"
node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force >"$uninstall_log" 2>&1 || {
cat "$uninstall_log"
exit 1
}
run_logged_sweep_command "uninstall $plugin_id" "$uninstall_log" \
node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force
if lifecycle_trace_enabled; then
cat "$uninstall_log"
fi

View File

@@ -14,22 +14,22 @@ async function loadCallGateway() {
throw new Error(`unable to find callGateway export in /app/dist (${candidates.join(", ")})`);
}
const callGateway = await loadCallGateway();
const DEFAULT_RAW_SCHEMA_ERROR =
"400 The following tools cannot be used with reasoning.effort 'minimal': web_search.";
const port = process.env.PORT;
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
const mode = process.argv[2];
const sessionKey = `agent:main:openai-web-search-minimal:${mode}`;
const message =
mode === "reject" ? "FORCE_SCHEMA_REJECT" : "Return exactly OPENCLAW_SCHEMA_E2E_OK.";
const id = mode === "reject" ? "schema-reject" : "schema-success";
if (!port || !token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
function readExpectedRawSchemaError() {
return process.env.RAW_SCHEMA_ERROR?.trim() || DEFAULT_RAW_SCHEMA_ERROR;
}
async function gatewayAgent(params) {
const port = process.env.PORT;
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
if (!port || !token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
}
try {
const callGateway = await loadCallGateway();
return {
ok: true,
value: await callGateway({
@@ -51,24 +51,51 @@ async function gatewayAgent(params) {
}
}
const result = await gatewayAgent({
sessionKey,
message,
thinking: "minimal",
deliver: false,
timeout: 180,
idempotencyKey: id,
});
function stringifyError(value) {
return value instanceof Error ? value.message || String(value) : String(value);
}
if (mode === "reject") {
console.error(result.ok ? JSON.stringify(result.value) : String(result.error));
process.exit(0);
function validateRejectResult(result, expectedRawSchemaError = readExpectedRawSchemaError()) {
if (result.ok) {
throw new Error(`reject mode unexpectedly completed: ${JSON.stringify(result.value)}`);
}
const errorText = stringifyError(result.error);
if (!errorText.includes(expectedRawSchemaError)) {
throw new Error(
`reject mode failed for an unexpected reason; expected ${JSON.stringify(
expectedRawSchemaError,
)} in ${JSON.stringify(errorText)}`,
);
}
return errorText;
}
if (!result.ok) {
throw toLintErrorObject(result.error, "Non-Error thrown");
}
if (result.value?.status !== "ok") {
throw new Error(`agent run did not complete successfully: ${JSON.stringify(result.value)}`);
async function main() {
const mode = process.argv[2];
const sessionKey = `agent:main:openai-web-search-minimal:${mode}`;
const message =
mode === "reject" ? "FORCE_SCHEMA_REJECT" : "Return exactly OPENCLAW_SCHEMA_E2E_OK.";
const id = mode === "reject" ? "schema-reject" : "schema-success";
const result = await gatewayAgent({
sessionKey,
message,
thinking: "minimal",
deliver: false,
timeout: 180,
idempotencyKey: id,
});
if (mode === "reject") {
console.error(validateRejectResult(result));
return;
}
if (!result.ok) {
throw toLintErrorObject(result.error, "Non-Error thrown");
}
if (result.value?.status !== "ok") {
throw new Error(`agent run did not complete successfully: ${JSON.stringify(result.value)}`);
}
}
function toLintErrorObject(value, fallbackMessage) {
@@ -84,3 +111,17 @@ function toLintErrorObject(value, fallbackMessage) {
}
return error;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
}
}
export const testing = {
DEFAULT_RAW_SCHEMA_ERROR,
validateRejectResult,
};

View File

@@ -22,14 +22,8 @@ mock_pid=""
gateway_pid=""
cleanup() {
if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then
kill "$gateway_pid" 2>/dev/null || true
wait "$gateway_pid" 2>/dev/null || true
fi
if [ -n "${mock_pid:-}" ] && kill -0 "$mock_pid" 2>/dev/null; then
kill "$mock_pid" 2>/dev/null || true
wait "$mock_pid" 2>/dev/null || true
fi
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
}
trap cleanup EXIT
@@ -73,22 +67,8 @@ for _ in $(seq 1 80); do
done
node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null
node "$entry" gateway --port "$PORT" --bind loopback --allow-unconfigured >"$GATEWAY_LOG" 2>&1 &
gateway_pid="$!"
for _ in $(seq 1 360); do
if ! kill -0 "$gateway_pid" 2>/dev/null; then
echo "gateway exited before listening" >&2
exit 1
fi
if node "$entry" gateway health \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \
--timeout 120000 \
--json >/dev/null 2>&1; then
break
fi
sleep 0.25
done
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360
node "$entry" gateway health \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \

View File

@@ -20,6 +20,25 @@ package_name="@openclaw/lifecycle-claw"
probe="scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs"
measure="scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs"
resource_dir="/tmp/openclaw-plugin-lifecycle-matrix"
pack_root=""
registry_root=""
cleanup() {
openclaw_plugins_cleanup_fixture_servers
rm -rf "$resource_dir"
if [ -n "$pack_root" ]; then
rm -rf "$pack_root"
fi
if [ -n "$registry_root" ]; then
rm -rf "$registry_root"
fi
rm -f \
/tmp/lifecycle-claw-1.0.0.tgz \
/tmp/lifecycle-claw-2.0.0.tgz \
/tmp/plugin-lifecycle-inspect-v1.json
}
trap cleanup EXIT
mkdir -p "$resource_dir"
summary_tsv="$resource_dir/resource-summary.tsv"
printf "phase\tmax_rss_kb\tcpu_seconds\twall_ms\tcpu_core_ratio\tsignal\n" >"$summary_tsv"
@@ -37,6 +56,7 @@ registry_root="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-registry.XXXXXX")"
pack_fixture_plugin "$pack_root/v1" /tmp/lifecycle-claw-1.0.0.tgz "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
pack_fixture_plugin "$pack_root/v2" /tmp/lifecycle-claw-2.0.0.tgz "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
start_npm_fixture_registry "$package_name" 1.0.0 /tmp/lifecycle-claw-1.0.0.tgz "$registry_root" "$package_name" 2.0.0 /tmp/lifecycle-claw-2.0.0.tgz
trap cleanup EXIT
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
node "$probe" assert-version "$plugin_id" 1.0.0

View File

@@ -19,7 +19,7 @@ node "$probe" seed
node scripts/e2e/lib/plugin-update/registry-server.mjs >/tmp/openclaw-e2e-registry.log 2>&1 &
registry_pid=$!
trap 'kill "$registry_pid" >/dev/null 2>&1 || true' EXIT
trap 'openclaw_e2e_stop_process "${registry_pid:-}"' EXIT
if ! node "$probe" wait-registry; then
echo "Local npm metadata registry failed to start"

View File

@@ -26,9 +26,13 @@ export SUCCESS_MARKER MOCK_REQUEST_LOG
mock_pid=""
gateway_pid=""
media_root=""
cleanup() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
if [ -n "${media_root:-}" ]; then
rm -rf "$media_root"
fi
}
trap cleanup EXIT
@@ -111,11 +115,12 @@ openclaw plugins list --json >/tmp/openclaw-release-media-memory-plugins.json \
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-media-memory-plugins.json memory-core
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
mkdir -p "$OPENCLAW_STATE_DIR/workspace/memory" /tmp/openclaw-release-media-memory
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yf7kAAAAASUVORK5CYII=' | base64 -d > /tmp/openclaw-release-media-memory/input.png
mkdir -p "$OPENCLAW_STATE_DIR/workspace/memory"
media_root="$(mktemp -d /tmp/openclaw-release-media-memory.XXXXXX)"
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yf7kAAAAASUVORK5CYII=' | base64 -d >"$media_root/input.png"
openclaw infer image describe \
--file /tmp/openclaw-release-media-memory/input.png \
--file "$media_root/input.png" \
--model openai/gpt-5.5 \
--prompt "Describe this image and return marker $SUCCESS_MARKER" \
--json >/tmp/openclaw-release-media-memory-describe.json 2>/tmp/openclaw-release-media-memory-describe.stderr.log
@@ -124,7 +129,7 @@ node scripts/e2e/lib/release-scenarios/assertions.mjs assert-image-describe /tmp
openclaw infer image generate \
--model openai/gpt-image-1 \
--prompt "Generate a tiny test image for $SUCCESS_MARKER" \
--output /tmp/openclaw-release-media-memory/generated.png \
--output "$media_root/generated.png" \
--json >/tmp/openclaw-release-media-memory-generate.json 2>/tmp/openclaw-release-media-memory-generate.stderr.log
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-image-generate /tmp/openclaw-release-media-memory-generate.json "$MOCK_REQUEST_LOG"

View File

@@ -222,6 +222,14 @@ export function isDependencyGuardAuthorizedForHead(comment, currentHeadSha) {
);
}
export function isDependencyGuardTrustedForHead(comment, currentHeadSha) {
return (
Boolean(currentHeadSha) &&
comment?.body?.includes("### Dependency graph changes noted") === true &&
dependencyGuardCommentHeadSha(comment) === currentHeadSha
);
}
export function securityApproverSet(value) {
return new Set(
String(value ?? "")
@@ -277,7 +285,7 @@ export function renderAuthorizedDependencyComment(override) {
"",
"### Dependency graph change authorized",
"",
"This PR includes dependency graph changes. A member of `@openclaw/openclaw-secops` authorized this exact head SHA with `/allow-dependencies-change`.",
"This PR includes dependency graph changes. A repository admin or member of `@openclaw/openclaw-secops` authorized this exact head SHA with `/allow-dependencies-change`.",
"",
`- Approved SHA: ${markdownCode(override.sha)}`,
`- Approved by: @${sanitizeDisplayValue(override.login)}`,
@@ -289,6 +297,22 @@ export function renderAuthorizedDependencyComment(override) {
return lines.join("\n");
}
export function renderTrustedDependencyComment({ actor, headSha }) {
return [
dependencyGraphGuardMarker,
"",
"### Dependency graph changes noted",
"",
"This PR includes dependency graph changes. The dependency guard is informational because the PR author is a repository admin or a member of `@openclaw/openclaw-secops`.",
"",
`- Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
`- Trusted actor: @${sanitizeDisplayValue(actor.login)}`,
`- Trusted role: ${markdownCode(actor.reason)}`,
"",
"Security review is still recommended before merge when the dependency graph change is intentional.",
].join("\n");
}
export function renderAutoscrubbedDependencyComment({ baseBranch, lockfileChanges, commitSha }) {
const safeBranch = sanitizeDisplayValue(baseBranch ?? "main");
const fileLines = lockfileChanges.map((path) => `- ${markdownCode(path)}`);
@@ -361,14 +385,14 @@ export function renderBlockedDependencyComment({
"",
"### Dependency graph changes are blocked",
"",
"OpenClaw does not accept dependency graph changes through PRs unless security explicitly authorizes the current head SHA. Dependency updates are generated internally by maintainers so external PRs cannot change the resolved graph.",
"OpenClaw does not accept dependency graph changes through PRs unless a repository admin or security explicitly authorizes the current head SHA. Dependency updates are generated internally by maintainers so external PRs cannot change the resolved graph.",
"",
"Detected dependency graph changes:",
...reasons,
...autoscrubLines,
...removalSteps,
"",
"If this PR intentionally needs a dependency graph change, ask a member of `@openclaw/openclaw-secops` to comment:",
"If this PR intentionally needs a dependency graph change, ask a repository admin or member of `@openclaw/openclaw-secops` to comment:",
"",
"```text",
allowDependenciesCommand,
@@ -415,6 +439,44 @@ function renderAutoscrubStatusLines(status) {
return [];
}
export function dependencyGuardTrustedActorCandidates({ pullRequest, event, currentHeadSha }) {
const eventHeadSha = event?.pull_request?.head?.sha;
const eventAfterSha = event?.after;
const eventMatchesCurrentHead =
Boolean(currentHeadSha) &&
(eventHeadSha === currentHeadSha || eventAfterSha === currentHeadSha);
if (!eventMatchesCurrentHead) {
return [];
}
const candidates = [];
const seen = new Set();
for (const [source, login] of [["pull request author", pullRequest?.user?.login]]) {
if (typeof login !== "string" || login.length === 0) {
continue;
}
const normalizedLogin = login.toLowerCase();
if (seen.has(normalizedLogin)) {
continue;
}
seen.add(normalizedLogin);
candidates.push({ login, source });
}
return candidates;
}
export async function findTrustedDependencyGuardActor({ candidates, isDependencyApprover }) {
for (const candidate of candidates) {
const role = await isDependencyApprover(candidate.login);
if (role) {
return {
login: candidate.login,
reason: `${candidate.source}; ${role}`,
};
}
}
return null;
}
function renderManifestChangeLine(change) {
return `- ${markdownCode(change.path)} changed ${change.fields.map(markdownCode).join(", ")}.`;
}
@@ -794,6 +856,98 @@ async function main() {
return;
}
const membershipCache = new Map();
const permissionCache = new Map();
const isSecurityMember = async (login) => {
const normalizedLogin = login.toLowerCase();
if (explicitSecurityApprovers.has(normalizedLogin)) {
return true;
}
if (membershipCache.has(normalizedLogin)) {
return membershipCache.get(normalizedLogin);
}
try {
const membership = await api.request(
`/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`,
);
const allowed = membership?.state === "active";
membershipCache.set(normalizedLogin, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
console.warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`);
}
membershipCache.set(normalizedLogin, false);
return false;
}
};
const isRepositoryAdmin = async (login) => {
const normalizedLogin = login.toLowerCase();
if (permissionCache.has(normalizedLogin)) {
return permissionCache.get(normalizedLogin);
}
try {
const result = await api.request(
`/repos/${owner}/${repo}/collaborators/${encodeURIComponent(login)}/permission`,
);
const allowed = result?.permission === "admin";
permissionCache.set(normalizedLogin, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
console.warn(`Could not verify repository permission for ${login}: ${error.message}`);
}
permissionCache.set(normalizedLogin, false);
return false;
}
};
const isDependencyApprover = async (login) => {
if (await isSecurityMember(login)) {
return securityTeamSlug;
}
if (await isRepositoryAdmin(login)) {
return "repository admin";
}
return null;
};
const currentHeadSha = pullRequest.head?.sha;
if (isDependencyGuardTrustedForHead(existingGuardComment, currentHeadSha)) {
if (mode === "detect") {
await setOutput("autoscrub", "false");
}
await writeSummary(
[
"## Dependency Guard",
"",
`Dependency graph change remains informational for a trusted actor at ${markdownCode(currentHeadSha)}.`,
].join("\n"),
);
console.log("Dependency graph change remains informational for this head SHA.");
return;
}
const trustedActor = await findTrustedDependencyGuardActor({
candidates: dependencyGuardTrustedActorCandidates({ pullRequest, event, currentHeadSha }),
isDependencyApprover,
});
if (trustedActor) {
if (mode === "detect") {
await setOutput("autoscrub", "false");
}
await upsertComment(
existingGuardComment,
renderTrustedDependencyComment({ actor: trustedActor, headSha: currentHeadSha }),
);
await writeSummary(
[
"## Dependency Guard",
"",
`Dependency graph change noted for trusted actor @${sanitizeDisplayValue(trustedActor.login)} and allowed to continue.`,
].join("\n"),
);
console.log("Dependency graph change noted for trusted actor; guard is informational.");
return;
}
const autoscrubCandidate = shouldAutoscrubDependencyLockfiles({
dependencyFiles,
lockfileChanges,
@@ -891,32 +1045,6 @@ async function main() {
};
}
}
const membershipCache = new Map();
const isSecurityMember = async (login) => {
const normalizedLogin = login.toLowerCase();
if (explicitSecurityApprovers.size > 0) {
return explicitSecurityApprovers.has(normalizedLogin);
}
if (membershipCache.has(login)) {
return membershipCache.get(login);
}
try {
const membership = await api.request(
`/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`,
);
const allowed = membership?.state === "active";
membershipCache.set(login, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
console.warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`);
}
membershipCache.set(login, false);
return false;
}
};
const currentHeadSha = pullRequest.head?.sha;
if (isDependencyGuardAuthorizedForHead(existingGuardComment, currentHeadSha)) {
await writeSummary(
[
@@ -931,7 +1059,7 @@ async function main() {
const override = await findDependencyOverrideCommandAsync({
comments,
expectedSha: dependencyOverrideExpectedSha(existingGuardComment, currentHeadSha),
isSecurityMember,
isSecurityMember: async (login) => Boolean(await isDependencyApprover(login)),
newerThan: existingGuardComment?.updated_at ?? existingGuardComment?.created_at,
});
if (override) {
@@ -943,7 +1071,7 @@ async function main() {
`Dependency graph change authorized by @${sanitizeDisplayValue(override.login)} for ${markdownCode(override.sha)}.`,
].join("\n"),
);
console.log("Dependency graph change authorized by security override.");
console.log("Dependency graph change authorized by trusted override.");
return;
}
@@ -958,9 +1086,11 @@ async function main() {
}),
);
await writeSummary(
"## Dependency Guard\n\nDependency graph changes are blocked without a current secops override.",
"## Dependency Guard\n\nDependency graph changes are blocked without a current admin or secops override.",
);
throw new Error(
"Dependency graph changes require removal or a current admin or secops override.",
);
throw new Error("Dependency graph changes require removal or a current secops override.");
}
if (import.meta.url === `file://${process.argv[1]}`) {

View File

@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { testing as voiceCallCliTesting } from "../../extensions/voice-call/src/cli.ts";
import { loadSessionLogs, loadSessionUsageTimeSeries } from "../../src/infra/session-cost-usage.ts";
import {
@@ -14,6 +15,15 @@ import {
resetDiagnosticPhasesForTest,
} from "../../src/logging/diagnostic-phase.ts";
export async function withProofTempRoot(callback) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-proof-"));
try {
return await callback(root);
} finally {
fs.rmSync(root, { force: true, recursive: true });
}
}
async function main() {
resetDiagnosticPhasesForTest();
recordDiagnosticPhase({
@@ -40,77 +50,80 @@ async function main() {
assert.equal(zeroPhases.length, 0);
console.log("getRecentDiagnosticPhases(0).length =", zeroPhases.length);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-proof-"));
const sessionFile = path.join(root, "s.jsonl");
fs.writeFileSync(
sessionFile,
[
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "a" },
}),
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:01:00.000Z",
message: {
role: "assistant",
content: "b",
provider: "openai",
model: "gpt-5.5",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { total: 0.001 },
await withProofTempRoot(async (root) => {
const sessionFile = path.join(root, "s.jsonl");
fs.writeFileSync(
sessionFile,
[
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "a" },
}),
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:01:00.000Z",
message: {
role: "assistant",
content: "b",
provider: "openai",
model: "gpt-5.5",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { total: 0.001 },
},
},
},
}),
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:02:00.000Z",
message: {
role: "assistant",
content: "c",
provider: "openai",
model: "gpt-5.5",
usage: {
input: 3,
output: 4,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 7,
cost: { total: 0.002 },
}),
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:02:00.000Z",
message: {
role: "assistant",
content: "c",
provider: "openai",
model: "gpt-5.5",
usage: {
input: 3,
output: 4,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 7,
cost: { total: 0.002 },
},
},
},
}),
].join("\n"),
);
}),
].join("\n"),
);
const logs = await loadSessionLogs({ sessionFile, limit: 0 });
const series = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 0 });
const positiveLogs = await loadSessionLogs({ sessionFile, limit: 10 });
const positiveSeries = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 10 });
assert.equal(logs?.length, 0);
assert.equal(series.points.length, 0);
assert.equal(positiveLogs?.length, 3);
assert.equal(positiveSeries.points.length, 2);
console.log("loadSessionLogs({ limit: 0 }).length =", logs?.length);
console.log(
"loadSessionUsageTimeSeries({ maxPoints: 0 }).points.length =",
series?.points.length,
);
const logs = await loadSessionLogs({ sessionFile, limit: 0 });
const series = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 0 });
const positiveLogs = await loadSessionLogs({ sessionFile, limit: 10 });
const positiveSeries = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 10 });
assert.equal(logs?.length, 0);
assert.equal(series.points.length, 0);
assert.equal(positiveLogs?.length, 3);
assert.equal(positiveSeries.points.length, 2);
console.log("loadSessionLogs({ limit: 0 }).length =", logs?.length);
console.log(
"loadSessionUsageTimeSeries({ maxPoints: 0 }).points.length =",
series?.points.length,
);
try {
voiceCallCliTesting.parseVoiceCallIntOption("nope", "--port", { min: 1 });
assert.fail("expected invalid voicecall --port value to throw");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
assert.equal(message, "Invalid numeric value for --port: nope");
console.log("parseVoiceCallIntOption('nope', '--port') error:", message);
}
try {
voiceCallCliTesting.parseVoiceCallIntOption("nope", "--port", { min: 1 });
assert.fail("expected invalid voicecall --port value to throw");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
assert.equal(message, "Invalid numeric value for --port: nope");
console.log("parseVoiceCallIntOption('nope', '--port') error:", message);
}
});
}
await main();
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -6,6 +6,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { maybeApplyTtsToPayload } from "../../packages/speech-core/src/tts.ts";
import { buildWebchatAudioContentBlocksFromReplyPayloads } from "../../src/gateway/server-methods/chat-webchat-media.ts";
import { createPluginRecord } from "../../src/plugins/loader-records.ts";
@@ -22,8 +23,16 @@ const noopLogger = {
debug() {},
};
export function cleanupProofArtifacts({ mediaPath, prefsPath }) {
if (mediaPath) {
fs.rmSync(path.dirname(mediaPath), { recursive: true, force: true });
}
fs.rmSync(prefsPath, { force: true });
}
async function main() {
resetPluginRuntimeStateForTest();
let mediaPath;
const pluginRegistry = createPluginRegistry({
logger: noopLogger,
runtime: {},
@@ -62,65 +71,65 @@ async function main() {
},
};
const accumulatedBlockText =
"WebChat streams block text; dispatch synthesizes one TTS tail with kind final.";
const blockResult = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: "webchat",
kind: "block",
});
console.log("maybeApplyTtsToPayload(kind=block).mediaUrl =", blockResult.mediaUrl ?? "(none)");
const tailResult = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: "webchat",
kind: "final",
});
console.log("maybeApplyTtsToPayload(kind=final).mediaUrl =", tailResult.mediaUrl ?? "(none)");
console.log(
"maybeApplyTtsToPayload(kind=final).trustedLocalMedia =",
tailResult.trustedLocalMedia ?? false,
);
const mediaPath = tailResult.mediaUrl;
if (!mediaPath || !fs.existsSync(mediaPath)) {
throw new Error("expected final-mode tail TTS to write a local media file");
}
// Same shape as dispatch-from-config accumulated block TTS-only final payload.
const ttsOnlyPayload = {
mediaUrl: tailResult.mediaUrl,
audioAsVoice: tailResult.audioAsVoice,
spokenText: accumulatedBlockText,
trustedLocalMedia: true,
};
console.log("dispatch ttsOnlyPayload.trustedLocalMedia =", ttsOnlyPayload.trustedLocalMedia);
const localRoots = [path.dirname(mediaPath)];
const trustedBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads([ttsOnlyPayload], {
localRoots,
});
const untrustedBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
[{ mediaUrl: mediaPath }],
{ localRoots },
);
console.log(
"buildWebchatAudioContentBlocksFromReplyPayloads(ttsOnlyPayload).length =",
trustedBlocks.length,
);
console.log(
"buildWebchatAudioContentBlocksFromReplyPayloads(untrusted).length =",
untrustedBlocks.length,
);
fs.rmSync(path.dirname(mediaPath), { recursive: true, force: true });
try {
fs.unlinkSync(prefsPath);
} catch {
// optional prefs file
const accumulatedBlockText =
"WebChat streams block text; dispatch synthesizes one TTS tail with kind final.";
const blockResult = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: "webchat",
kind: "block",
});
console.log("maybeApplyTtsToPayload(kind=block).mediaUrl =", blockResult.mediaUrl ?? "(none)");
const tailResult = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: "webchat",
kind: "final",
});
console.log("maybeApplyTtsToPayload(kind=final).mediaUrl =", tailResult.mediaUrl ?? "(none)");
console.log(
"maybeApplyTtsToPayload(kind=final).trustedLocalMedia =",
tailResult.trustedLocalMedia ?? false,
);
mediaPath = tailResult.mediaUrl;
if (!mediaPath || !fs.existsSync(mediaPath)) {
throw new Error("expected final-mode tail TTS to write a local media file");
}
// Same shape as dispatch-from-config accumulated block TTS-only final payload.
const ttsOnlyPayload = {
mediaUrl: tailResult.mediaUrl,
audioAsVoice: tailResult.audioAsVoice,
spokenText: accumulatedBlockText,
trustedLocalMedia: true,
};
console.log("dispatch ttsOnlyPayload.trustedLocalMedia =", ttsOnlyPayload.trustedLocalMedia);
const localRoots = [path.dirname(mediaPath)];
const trustedBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads([ttsOnlyPayload], {
localRoots,
});
const untrustedBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
[{ mediaUrl: mediaPath }],
{ localRoots },
);
console.log(
"buildWebchatAudioContentBlocksFromReplyPayloads(ttsOnlyPayload).length =",
trustedBlocks.length,
);
console.log(
"buildWebchatAudioContentBlocksFromReplyPayloads(untrusted).length =",
untrustedBlocks.length,
);
} finally {
cleanupProofArtifacts({ mediaPath, prefsPath });
resetPluginRuntimeStateForTest();
}
}
await main();
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -28,11 +28,6 @@ function workspacePathsOverlap(left: string, right: string): boolean {
);
}
/**
* Find other configured agents whose workspaces overlap the target deletion
* workspace. Deletion callers use this to avoid removing shared parent/child
* directories that still belong to another agent.
*/
export function findOverlappingWorkspaceAgentIds(
cfg: OpenClawConfig,
agentId: string,

View File

@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";
const LOGIN_HINT_SENTINEL = "<<login-hint-for-provider>>";
vi.mock("../provider-auth-recovery-hint.js", () => ({
buildProviderAuthRecoveryHint: (params: { provider: string }) =>
`${LOGIN_HINT_SENTINEL}:${params.provider}`,
}));
import type { FailoverReason } from "../embedded-agent-helpers/types.js";
import { formatAuthProfileFailureMessage } from "./failure-copy.js";
const PROVIDER = "openai-codex";
const REASONS_WITH_RECOVERY: readonly FailoverReason[] = [
"auth",
"session_expired",
"auth_permanent",
"billing",
];
const REASONS_TRANSIENT: readonly FailoverReason[] = [
"rate_limit",
"overloaded",
"timeout",
"server_error",
"model_not_found",
];
describe("formatAuthProfileFailureMessage", () => {
describe("recovery-hint dispatch", () => {
it("includes the login command for reasons the user can act on", () => {
for (const reason of REASONS_WITH_RECOVERY) {
const message = formatAuthProfileFailureMessage({
reason,
provider: PROVIDER,
allInCooldown: true,
});
expect(message, `reason=${reason}`).toContain(`${LOGIN_HINT_SENTINEL}:${PROVIDER}`);
}
});
it("omits the login command for transient cooldown reasons", () => {
for (const reason of REASONS_TRANSIENT) {
const message = formatAuthProfileFailureMessage({
reason,
provider: PROVIDER,
allInCooldown: true,
});
expect(message, `reason=${reason}`).not.toContain(LOGIN_HINT_SENTINEL);
}
});
});
describe("reason coverage", () => {
it("renders distinct copy across the major reason classes", () => {
const samples = (["auth", "billing", "rate_limit", "timeout"] as const).map((reason) =>
formatAuthProfileFailureMessage({ reason, provider: PROVIDER, allInCooldown: true }),
);
expect(new Set(samples).size).toBe(samples.length);
});
it("always mentions the provider name", () => {
for (const reason of [...REASONS_WITH_RECOVERY, ...REASONS_TRANSIENT, "unknown"] as const) {
const message = formatAuthProfileFailureMessage({
reason,
provider: PROVIDER,
allInCooldown: true,
});
expect(message, `reason=${reason}`).toContain(PROVIDER);
}
});
});
describe("cause handling", () => {
it("returns the cause text verbatim when the reason has no actionable copy", () => {
const cause = new Error("upstream provider returned 502");
const message = formatAuthProfileFailureMessage({
reason: "unknown",
provider: PROVIDER,
allInCooldown: false,
cause,
});
expect(message).toBe(cause.message);
});
it("appends a diagnostic suffix when the cause adds detail beyond the description", () => {
const message = formatAuthProfileFailureMessage({
reason: "auth",
provider: PROVIDER,
allInCooldown: false,
cause: new Error("invalid_grant"),
});
expect(message).toContain("(invalid_grant)");
});
it("does not append a diagnostic suffix when the cause text is already in the description", () => {
// Derive the description sentence by formatting once without a cause, then stripping
// the mocked recovery hint. Using that sentence as the cause should be deduped.
const withoutCause = formatAuthProfileFailureMessage({
reason: "auth",
provider: PROVIDER,
allInCooldown: false,
});
const description = withoutCause
.replace(new RegExp(`\\s*${LOGIN_HINT_SENTINEL}:[^\\s]+\\s*$`), "")
.trim();
const withDuplicateCause = formatAuthProfileFailureMessage({
reason: "auth",
provider: PROVIDER,
allInCooldown: false,
cause: new Error(description),
});
expect(withDuplicateCause).toBe(withoutCause);
});
it("produces non-empty copy for unknown reasons with no cause", () => {
const message = formatAuthProfileFailureMessage({
reason: "unknown",
provider: PROVIDER,
allInCooldown: false,
});
expect(message).toContain(PROVIDER);
expect(message.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,130 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { FailoverReason } from "../embedded-agent-helpers/types.js";
import { buildProviderAuthRecoveryHint } from "../provider-auth-recovery-hint.js";
export type AuthProfileFailureCopyParams = {
reason: FailoverReason;
provider: string;
/**
* True when the failure was reached because every configured profile is in
* cooldown / blocked. False when an attempt to use a specific profile threw
* (e.g. credential lookup failed). The two paths produce different copy
* because only the cooldown case implies "wait or rotate"; the other case
* implies "the credential itself is broken".
*/
allInCooldown: boolean;
/**
* Underlying error that triggered the failover, if any. Used to append a
* short diagnostic suffix and to fall back to the original message when no
* structured recovery copy applies.
*/
cause?: unknown;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
};
function describeReason(
reason: FailoverReason,
provider: string,
allInCooldown: boolean,
): string | null {
if (allInCooldown) {
switch (reason) {
case "auth":
case "session_expired":
return `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`;
case "auth_permanent":
return `${provider} isn't accepting your saved login anymore.`;
case "billing":
return `${provider} rejected the request — looks like a billing issue on the account.`;
case "rate_limit":
return `${provider} is asking us to slow down. Please wait a moment before trying again.`;
case "overloaded":
return `${provider} is overloaded right now. Please wait a moment before trying again.`;
case "timeout":
return `${provider} hasn't been responding. Please wait a moment before trying again.`;
case "model_not_found":
return `${provider} can't find the model you're using right now.`;
case "server_error":
return `${provider} is having issues right now. Please wait a moment before trying again.`;
default:
return `Couldn't reach ${provider} with any of your saved logins right now.`;
}
}
switch (reason) {
case "auth":
case "session_expired":
return `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`;
case "auth_permanent":
return `${provider} isn't accepting your saved login.`;
case "billing":
return `${provider} rejected the request — looks like a billing issue on the account.`;
default:
return null;
}
}
function shouldIncludeRecoveryHint(reason: FailoverReason): boolean {
switch (reason) {
case "auth":
case "auth_permanent":
case "session_expired":
case "billing":
return true;
case "rate_limit":
case "overloaded":
case "timeout":
case "server_error":
case "model_not_found":
return false;
default:
return true;
}
}
function diagnosticSuffix(cause: unknown, primary: string): string | null {
if (cause === undefined || cause === null) {
return null;
}
const text = formatErrorMessage(cause).trim();
if (!text || primary.includes(text)) {
return null;
}
return ` (${text})`;
}
/**
* Single source of truth for user-facing copy when an auth-profile rotation
* fails. Composes a reason-specific sentence with an actionable next-step
* derived from the provider's plugin manifest (`buildProviderAuthRecoveryHint`).
*
* Falls back to the underlying error's text when the reason maps to nothing
* actionable, so we never produce worse copy than the raw error.
*/
export function formatAuthProfileFailureMessage(params: AuthProfileFailureCopyParams): string {
const description = describeReason(params.reason, params.provider, params.allInCooldown);
if (!description) {
const causeText = params.cause ? formatErrorMessage(params.cause).trim() : "";
if (causeText) {
return causeText;
}
return `Couldn't reach ${params.provider} with any of your saved logins right now.`;
}
const hint = shouldIncludeRecoveryHint(params.reason)
? buildProviderAuthRecoveryHint({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
: null;
const suffix = diagnosticSuffix(params.cause, description);
const parts = [description];
if (hint) {
parts.push(hint);
}
const message = parts.join(" ");
return suffix ? `${message}${suffix}` : message;
}

View File

@@ -5,11 +5,6 @@ import { normalizeProviderId } from "./model-selection.js";
const CLAUDE_CLI_BACKEND_ID = "claude-cli";
/**
* Hash CLI-session reuse inputs before persisting them into session metadata.
* The stored value is only an equality token, so prompt/cwd/MCP inputs are not
* written back into the session store in plaintext.
*/
export function hashCliSessionText(value: string | undefined): string | undefined {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
@@ -18,11 +13,6 @@ export function hashCliSessionText(value: string | undefined): string | undefine
return crypto.createHash("sha256").update(trimmed).digest("hex");
}
/**
* Resolve the stored CLI session binding for a provider. New structured
* bindings win, older provider-id maps are still read, and the legacy
* Claude-only field is retained as a final migration fallback.
*/
export function getCliSessionBinding(
entry: SessionEntry | undefined,
provider: string,
@@ -61,7 +51,6 @@ export function getCliSessionBinding(
return undefined;
}
/** Return only the reusable CLI session id for callers that do not need invalidation metadata. */
export function getCliSessionId(
entry: SessionEntry | undefined,
provider: string,
@@ -69,19 +58,10 @@ export function getCliSessionId(
return getCliSessionBinding(entry, provider)?.sessionId;
}
/**
* Store a CLI session id without reuse metadata. Prefer `setCliSessionBinding`
* when the caller can also persist auth, prompt, cwd, or MCP hashes.
*/
export function setCliSessionId(entry: SessionEntry, provider: string, sessionId: string): void {
setCliSessionBinding(entry, provider, { sessionId });
}
/**
* Persist a provider-scoped CLI session binding in all currently supported
* session-store shapes. The duplicate legacy writes keep older readers working
* while structured bindings carry the invalidation inputs for newer runtimes.
*/
export function setCliSessionBinding(
entry: SessionEntry,
provider: string,
@@ -129,11 +109,6 @@ export function setCliSessionBinding(
}
}
/**
* Clear one provider's CLI session binding across structured and legacy fields.
* Other providers' bindings stay intact so a model switch only invalidates the
* backend that actually failed or changed reuse conditions.
*/
export function clearCliSession(entry: SessionEntry, provider: string): void {
const normalized = normalizeProviderId(provider);
if (entry.cliSessionBindings?.[normalized] !== undefined) {
@@ -151,18 +126,12 @@ export function clearCliSession(entry: SessionEntry, provider: string): void {
}
}
/** Clear every persisted CLI session binding from a session entry. */
export function clearAllCliSessions(entry: SessionEntry): void {
entry.cliSessionBindings = undefined;
entry.cliSessionIds = undefined;
entry.claudeCliSessionId = undefined;
}
/**
* Decide whether a stored CLI session can be reused under the current run
* inputs. Auth, system prompt, cwd, and MCP changes invalidate the session
* unless the binding was explicitly marked `forceReuse`.
*/
export function resolveCliSessionReuse(params: {
binding?: CliSessionBinding;
authProfileId?: string;
@@ -194,8 +163,6 @@ export function resolveCliSessionReuse(params: {
const currentMcpResumeHash = normalizeOptionalString(params.mcpResumeHash);
const storedAuthProfileId = normalizeOptionalString(binding?.authProfileId);
const storedAuthEpoch = normalizeOptionalString(binding?.authEpoch);
// Versioned auth epochs let a rotated profile keep reuse when the underlying
// auth material is known to be unchanged, avoiding unnecessary CLI restarts.
const hasMatchingVersionedAuthEpoch =
binding?.authEpochVersion === params.authEpochVersion &&
storedAuthEpoch !== undefined &&

View File

@@ -7,6 +7,7 @@ import {
isProfileInCooldown,
resolveProfilesUnavailableReason,
} from "../../auth-profiles.js";
import { formatAuthProfileFailureMessage } from "../../auth-profiles/failure-copy.js";
import {
classifyFailoverReason,
isFailoverErrorMessage,
@@ -324,16 +325,25 @@ export function createEmbeddedRunAuthController(params: {
}): never => {
const provider = params.getProvider();
const modelId = params.getModelId();
const fallbackMessage = `No available auth profile for ${provider} (all in cooldown or unavailable).`;
const message =
const messageForReason =
failoverParams.message?.trim() ||
(failoverParams.error ? formatErrorMessage(failoverParams.error).trim() : "") ||
fallbackMessage;
(failoverParams.error ? formatErrorMessage(failoverParams.error).trim() : "");
const reason = resolveAuthProfileFailoverReason({
allInCooldown: failoverParams.allInCooldown,
message,
message: messageForReason,
profileIds: params.profileCandidates,
});
const message =
failoverParams.message?.trim() ||
formatAuthProfileFailureMessage({
reason,
provider,
allInCooldown: failoverParams.allInCooldown,
cause: failoverParams.error,
config: params.config,
workspaceDir: params.workspaceDir,
env: process.env,
});
if (params.fallbackConfigured) {
throw new FailoverError(message, {
reason,

View File

@@ -291,6 +291,22 @@ describe("memory search config", () => {
expect(resolveMemorySearchSyncConfig(cfg, "main")?.embeddingBatchTimeoutSeconds).toBe(600);
});
it("keeps memory watching enabled by default in gateway mode", () => {
const cfg = asConfig({
gateway: { mode: "local" },
agents: {
defaults: {
memorySearch: {
provider: "openai",
},
},
},
});
expect(resolveMemorySearchConfig(cfg, "main")?.sync.watch).toBe(true);
expect(resolveMemorySearchSyncConfig(cfg, "main")?.watch).toBe(true);
});
it("merges defaults and overrides", () => {
const cfg = asConfig({
agents: {

View File

@@ -111,6 +111,11 @@ export type ResolvedMemorySearchConfig = {
};
export type ResolvedMemorySearchSyncConfig = ResolvedMemorySearchConfig["sync"];
export type MemorySearchResolvePurpose = "default" | "status" | "cli";
export type MemorySearchResolveOptions = {
/** @deprecated No-op; kept for resolver call-site compatibility. */
purpose?: MemorySearchResolvePurpose;
};
const DEFAULT_CHUNK_TOKENS = 400;
const DEFAULT_CHUNK_OVERLAP = 80;
@@ -468,6 +473,7 @@ function resolveSyncConfig(
export function resolveMemorySearchConfig(
cfg: OpenClawConfig,
agentId: string,
_options?: MemorySearchResolveOptions,
): ResolvedMemorySearchConfig | null {
const defaults = cfg.agents?.defaults?.memorySearch;
const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch;
@@ -500,6 +506,7 @@ export function resolveMemorySearchConfig(
export function resolveMemorySearchSyncConfig(
cfg: OpenClawConfig,
agentId: string,
_options?: MemorySearchResolveOptions,
): ResolvedMemorySearchSyncConfig | null {
const defaults = cfg.agents?.defaults?.memorySearch;
const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch;

View File

@@ -1,8 +1,8 @@
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveProviderAuthAliasMap } from "../agents/provider-auth-aliases.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveManifestProviderAuthChoices } from "../plugins/provider-auth-choices.js";
import { normalizeProviderId } from "./model-selection.js";
import { resolveProviderAuthAliasMap } from "./provider-auth-aliases.js";
function normalizeProviderIdForAuth(
providerId: string,

View File

@@ -5,11 +5,6 @@ import { ensureCustomApiRegistered } from "./custom-api-registry.js";
import { createTransportAwareStreamFnForModel } from "./provider-transport-stream.js";
import type { StreamFn } from "./runtime/index.js";
/**
* Resolve and register the stream function for a concrete model. Provider
* plugin streams win, transport-aware built-ins are the fallback, and successful
* resolution updates the custom API registry for downstream runtime dispatch.
*/
export function registerProviderStreamForModel<TApi extends Api>(params: {
model: Model<TApi>;
cfg?: OpenClawConfig;

View File

@@ -190,6 +190,7 @@ function summarizeSubagentRuns(runs: ReturnType<typeof listSubagentRunsForReques
endedReason: run.endedReason,
pauseReason: run.pauseReason,
outcome: run.outcome?.status,
outcomeError: run.outcome?.status === "error" ? run.outcome.error : undefined,
delivery: run.delivery?.status,
deliveryError: run.delivery?.lastError,
suppressAnnounceReason: run.suppressAnnounceReason,

View File

@@ -1372,9 +1372,7 @@ describe("steerControlledSubagentRun", () => {
});
setSubagentControlDepsForTest({
callGateway: async <T = Record<string, unknown>>(
request: CallGatewayOptions,
) => {
callGateway: async <T = Record<string, unknown>>(request: CallGatewayOptions) => {
if (request.method === "agent.wait") {
return {} as T;
}
@@ -1419,4 +1417,67 @@ describe("steerControlledSubagentRun", () => {
text: "steered yielded steer task.",
});
});
it("rotates the child session when restarting a previously active session", async () => {
const childSessionKey = "agent:main:subagent:active-steer-worker";
const storePath = writeSessionStoreFixture("steer-restart-session", {
[childSessionKey]: {
sessionId: "old-child-session",
updatedAt: Date.now(),
},
});
const agentCalls: CallGatewayOptions[] = [];
addSubagentRunForTests({
runId: "run-active-steer",
childSessionKey,
controllerSessionKey: "agent:main:main",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "active steer task",
cleanup: "keep",
createdAt: Date.now() - 5_000,
startedAt: Date.now() - 4_000,
});
setSubagentControlDepsForTest({
callGateway: async <T = Record<string, unknown>>(request: CallGatewayOptions) => {
if (request.method === "agent.wait") {
return {} as T;
}
if (request.method === "agent") {
agentCalls.push(request);
return { runId: "run-active-steer-restarted" } as T;
}
throw new Error(`unexpected method: ${request.method}`);
},
});
const result = await steerControlledSubagentRun({
cfg: cfgWithSessionStore(storePath),
controller: {
controllerSessionKey: "agent:main:main",
callerSessionKey: "agent:main:main",
callerIsSubagent: false,
controlScope: "children",
},
entry: {
runId: "run-active-steer",
childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
controllerSessionKey: "agent:main:main",
task: "active steer task",
cleanup: "keep",
createdAt: Date.now() - 5_000,
startedAt: Date.now() - 4_000,
},
message: "updated direction",
});
expect(result.status).toBe("accepted");
expect(result.sessionId).toBeTypeOf("string");
expect(result.sessionId).not.toBe("old-child-session");
const agentParams = agentCalls[0]?.params as { sessionId?: string } | undefined;
expect(agentParams?.sessionId).toBe(result.sessionId);
});
});

View File

@@ -527,6 +527,7 @@ export async function steerControlledSubagentRun(params: {
typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim()
? targetSession.entry.sessionId.trim()
: undefined;
const restartSessionId = sessionId ? crypto.randomUUID() : undefined;
if (sessionId) {
const runtime = await resolveSubagentControlRuntime();
@@ -561,7 +562,7 @@ export async function steerControlledSubagentRun(params: {
params: {
message: params.message,
sessionKey: params.entry.childSessionKey,
sessionId,
sessionId: restartSessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
@@ -580,7 +581,7 @@ export async function steerControlledSubagentRun(params: {
status: "error",
runId,
sessionKey: params.entry.childSessionKey,
sessionId,
sessionId: restartSessionId,
error,
};
}
@@ -597,7 +598,7 @@ export async function steerControlledSubagentRun(params: {
status: "error",
runId,
sessionKey: params.entry.childSessionKey,
sessionId,
sessionId: restartSessionId,
error: "failed to replace steered subagent run",
};
}
@@ -606,7 +607,7 @@ export async function steerControlledSubagentRun(params: {
status: "accepted",
runId,
sessionKey: params.entry.childSessionKey,
sessionId,
sessionId: restartSessionId,
mode: "restart",
label: resolveSubagentLabel(params.entry),
text: `steered ${resolveSubagentLabel(params.entry)}.`,

View File

@@ -6,6 +6,10 @@ import { isRecord } from "../utils.js";
import { asBoolean } from "../utils/boolean.js";
import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
// Read-only status commands project a safe subset of account fields into snapshots
// so renderers can preserve "configured but unavailable" state without touching
// strict runtime-only credential helpers.
const CREDENTIAL_STATUS_KEYS = [
"tokenStatus",
"botTokenStatus",
@@ -29,8 +33,6 @@ function readNullableNumber(
record: Record<string, unknown>,
key: string,
): number | null | undefined {
// Preserve explicit null timestamps; status callers use null to distinguish
// "known empty" from an omitted/unsupported field.
if (record[key] === null) {
return null;
}
@@ -55,7 +57,6 @@ function readCredentialStatus(record: Record<string, unknown>, key: CredentialSt
: undefined;
}
/** Infers configured state from any credential status field on an account snapshot-like object. */
export function resolveConfiguredFromCredentialStatuses(account: unknown): boolean | undefined {
const record = isRecord(account) ? account : null;
if (!record) {
@@ -69,8 +70,6 @@ export function resolveConfiguredFromCredentialStatuses(account: unknown): boole
}
sawCredentialStatus = true;
if (status !== "missing") {
// Any configured credential is enough for coarse account presence; callers
// that require every credential use resolveConfiguredFromRequiredCredentialStatuses.
return true;
}
}
@@ -93,15 +92,12 @@ export function resolveConfiguredFromRequiredCredentialStatuses(
}
sawCredentialStatus = true;
if (status === "missing") {
// Required-credential checks are all-or-nothing so multi-token accounts
// do not appear configured when one mandatory credential is absent.
return false;
}
}
return sawCredentialStatus ? true : undefined;
}
/** Returns true when a credential exists but is unavailable to the current process. */
export function hasConfiguredUnavailableCredentialStatus(account: unknown): boolean {
const record = isRecord(account) ? account : null;
if (!record) {
@@ -112,7 +108,6 @@ export function hasConfiguredUnavailableCredentialStatus(account: unknown): bool
);
}
/** Returns true when an account snapshot exposes an actual credential or available status. */
export function hasResolvedCredentialValue(account: unknown): boolean {
const record = isRecord(account) ? account : null;
if (!record) {
@@ -125,7 +120,6 @@ export function hasResolvedCredentialValue(account: unknown): boolean {
);
}
/** Projects non-secret credential source/status fields into a channel account snapshot. */
export function projectCredentialSnapshotFields(
account: unknown,
): Pick<
@@ -149,8 +143,6 @@ export function projectCredentialSnapshotFields(
const appTokenSource = normalizeOptionalString(record.appTokenSource);
const signingSecretSource = normalizeOptionalString(record.signingSecretSource);
// Only expose source/status metadata. Raw credential fields are intentionally
// omitted here because channel snapshots are safe to display in status output.
return {
...(tokenSource ? { tokenSource } : {}),
...(botTokenSource ? { botTokenSource } : {}),
@@ -174,7 +166,6 @@ export function projectCredentialSnapshotFields(
};
}
/** Projects a safe read-only account snapshot, redacting URL credentials and raw secrets. */
export function projectSafeChannelAccountSnapshotFields(
account: unknown,
): Partial<ChannelAccountSnapshot> {
@@ -241,7 +232,6 @@ export function projectSafeChannelAccountSnapshotFields(
? { allowFrom: readStringArray(record, "allowFrom") }
: {}),
...projectCredentialSnapshotFields(account),
// Status output may display base URLs, but embedded credentials must never leak.
...(baseUrl ? { baseUrl: stripUrlUserInfo(baseUrl) } : {}),
...(readBoolean(record, "allowUnmentionedGroups") !== undefined
? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") }

View File

@@ -2,14 +2,12 @@ export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions"
export type WhatsAppAckReactionMode = "always" | "mentions" | "never";
/** Pending ack reaction plus the provider callback needed to remove it after a reply. */
export type AckReactionHandle = {
ackReactionPromise: Promise<boolean>;
ackReactionValue: string;
remove: () => Promise<void>;
};
/** Channel-neutral facts used to decide whether an inbound message gets an ack reaction. */
export type AckReactionGateParams = {
scope: AckReactionScope | undefined;
isDirect: boolean;
@@ -21,7 +19,6 @@ export type AckReactionGateParams = {
shouldBypassMention?: boolean;
};
/** Apply channel-neutral ack reaction scope rules before a provider sends an emoji. */
export function shouldAckReaction(params: AckReactionGateParams): boolean {
const scope = params.scope ?? "group-mentions";
if (scope === "off" || scope === "none") {
@@ -51,7 +48,6 @@ export function shouldAckReaction(params: AckReactionGateParams): boolean {
return false;
}
/** Adapt WhatsApp's direct/group knobs onto the shared ack reaction gate. */
export function shouldAckReactionForWhatsApp(params: {
emoji: string;
isDirect: boolean;
@@ -88,7 +84,6 @@ export function shouldAckReactionForWhatsApp(params: {
});
}
/** Start sending an ack reaction and retain enough state for optional cleanup. */
export function createAckReactionHandle(params: {
ackReactionValue: string;
send: () => Promise<void>;
@@ -120,7 +115,6 @@ export function createAckReactionHandle(params: {
};
}
/** Remove an ack reaction only after the send path confirmed it was applied. */
export function removeAckReactionAfterReply(params: {
removeAfterReply: boolean;
ackReactionPromise: Promise<boolean> | null;
@@ -145,7 +139,6 @@ export function removeAckReactionAfterReply(params: {
});
}
/** Convenience wrapper for removing a stored ack reaction handle after reply delivery. */
export function removeAckReactionHandleAfterReply(params: {
removeAfterReply: boolean;
ackReaction: AckReactionHandle | null | undefined;

View File

@@ -1,9 +1,7 @@
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
/** Prefix used in allow-from entries that delegate membership to an access group. */
export const ACCESS_GROUP_ALLOW_FROM_PREFIX = "accessGroup:";
/** Parses an access-group allow-from entry and returns the referenced group name. */
export function parseAccessGroupAllowFromEntry(entry: string): string | null {
const trimmed = entry.trim();
if (!trimmed.startsWith(ACCESS_GROUP_ALLOW_FROM_PREFIX)) {
@@ -13,14 +11,11 @@ export function parseAccessGroupAllowFromEntry(entry: string): string | null {
return name.length > 0 ? name : null;
}
/** Merges configured and pairing-store DM allowlists according to the active DM policy. */
export function mergeDmAllowFromSources(params: {
allowFrom?: Array<string | number>;
storeAllowFrom?: Array<string | number>;
dmPolicy?: string;
}): string[] {
// Explicit allowlist/open policy owns the effective list; pairing-store entries only supplement
// pairing/default policies so old approved users do not override a stricter configured list.
const storeEntries =
params.dmPolicy === "allowlist" || params.dmPolicy === "open"
? []
@@ -28,7 +23,6 @@ export function mergeDmAllowFromSources(params: {
return normalizeStringEntries([...(params.allowFrom ?? []), ...storeEntries]);
}
/** Resolves group allow-from entries with optional fallback to the generic allowFrom list. */
export function resolveGroupAllowFromSources(params: {
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
@@ -46,7 +40,6 @@ export function resolveGroupAllowFromSources(params: {
return normalizeStringEntries(scoped);
}
/** Returns the first defined value without treating null/false/empty string as missing. */
export function firstDefined<T>(...values: Array<T | undefined>) {
for (const value of values) {
if (value !== undefined) {
@@ -56,7 +49,6 @@ export function firstDefined<T>(...values: Array<T | undefined>) {
return undefined;
}
/** Checks a normalized sender id against a compiled allowlist summary. */
export function isSenderIdAllowed(
allow: { entries: string[]; hasWildcard: boolean; hasEntries: boolean },
senderId: string | undefined,

View File

@@ -3,7 +3,6 @@ import {
normalizeOptionalLowercaseString,
} from "@openclaw/normalization-core/string-coerce";
/** Candidate class that matched an allowlist entry. */
export type AllowlistMatchSource =
| "wildcard"
| "id"
@@ -16,32 +15,23 @@ export type AllowlistMatchSource =
| "slug"
| "localpart";
/** Allowlist decision plus optional match metadata for diagnostics. */
export type AllowlistMatch<TSource extends string = AllowlistMatchSource> = {
/** Whether the candidate was allowed. */
allowed: boolean;
/** Config entry or wildcard that matched. */
matchKey?: string;
/** Candidate source that matched the config entry. */
matchSource?: TSource;
};
/** Precompiled allowlist for repeated candidate checks. */
export type CompiledAllowlist = {
/** Normalized allowlist entries. */
set: ReadonlySet<string>;
/** Whether the wildcard entry allows every candidate. */
wildcard: boolean;
};
/** Formats match metadata for compact logs and tests. */
export function formatAllowlistMatchMeta(
match?: { matchKey?: string; matchSource?: string } | null,
): string {
return `matchKey=${match?.matchKey ?? "none"} matchSource=${match?.matchSource ?? "none"}`;
}
/** Compiles already-normalized allowlist entries into a lookup set. */
export function compileAllowlist(entries: ReadonlyArray<string>): CompiledAllowlist {
const set = new Set(entries.filter(Boolean));
return {
@@ -58,7 +48,6 @@ function compileSimpleAllowlist(entries: ReadonlyArray<string | number>): Compil
);
}
/** Checks candidates in order, returning the first exact allowlist match. */
export function resolveAllowlistCandidates<TSource extends string>(params: {
compiledAllowlist: CompiledAllowlist;
candidates: Array<{ value?: string; source: TSource }>;
@@ -78,7 +67,6 @@ export function resolveAllowlistCandidates<TSource extends string>(params: {
return { allowed: false };
}
/** Resolves an allowlist decision with wildcard taking precedence over candidate checks. */
export function resolveCompiledAllowlistMatch<TSource extends string>(params: {
compiledAllowlist: CompiledAllowlist;
candidates: Array<{ value?: string; source: TSource }>;
@@ -92,7 +80,6 @@ export function resolveCompiledAllowlistMatch<TSource extends string>(params: {
return resolveAllowlistCandidates(params);
}
/** Compiles an allowlist and resolves it against ordered candidate values. */
export function resolveAllowlistMatchByCandidates<TSource extends string>(params: {
allowList: ReadonlyArray<string>;
candidates: Array<{ value?: string; source: TSource }>;
@@ -103,14 +90,12 @@ export function resolveAllowlistMatchByCandidates<TSource extends string>(params
});
}
/** Resolves the common id/name allowlist shape used by channel sender checks. */
export function resolveAllowlistMatchSimple(params: {
allowFrom: ReadonlyArray<string | number>;
senderId: string;
senderName?: string | null;
allowNameMatching?: boolean;
}): AllowlistMatch<"wildcard" | "id" | "name"> {
// Compile from the current array contents so in-place config edits are visible immediately.
const allowFrom = compileSimpleAllowlist(params.allowFrom);
if (allowFrom.set.size === 0) {
@@ -126,7 +111,6 @@ export function resolveAllowlistMatchSimple(params: {
compiledAllowlist: allowFrom,
candidates: [
{ value: senderId, source: "id" },
// Name matching is opt-in because display names can be mutable or ambiguous.
...(params.allowNameMatching === true && senderName
? ([{ value: senderName, source: "name" as const }] satisfies Array<{
value?: string;

View File

@@ -30,7 +30,6 @@ function dedupeAllowlistEntries(entries: string[]): string[] {
return deduped;
}
/** Appends resolved ids to an allowlist while preserving first-seen casing/order. */
export function mergeAllowlist(params: {
existing?: Array<string | number>;
additions: string[];
@@ -38,7 +37,6 @@ export function mergeAllowlist(params: {
return dedupeAllowlistEntries([...mapAllowFromEntries(params.existing), ...params.additions]);
}
/** Builds resolved/unresolved summaries plus id additions from resolver output. */
export function buildAllowlistResolutionSummary<T extends AllowlistUserResolutionLike>(
resolvedUsers: T[],
opts?: { formatResolved?: (entry: T) => string; formatUnresolved?: (entry: T) => string },
@@ -95,7 +93,6 @@ export function canonicalizeAllowlistWithResolvedIds<
return dedupeAllowlistEntries(canonicalized);
}
/** Rewrites nested `users` arrays in channel config entries after allowlist resolution. */
export function patchAllowlistUsersInConfigEntries<
T extends AllowlistUserResolutionLike,
TEntries extends Record<string, unknown>,
@@ -113,7 +110,6 @@ export function patchAllowlistUsersInConfigEntries<
if (!Array.isArray(users) || users.length === 0) {
continue;
}
// Merge keeps user-facing aliases; canonicalize replaces aliases with stable ids when possible.
const resolvedUsers =
params.strategy === "canonicalize"
? canonicalizeAllowlistWithResolvedIds({
@@ -135,7 +131,6 @@ export function patchAllowlistUsersInConfigEntries<
return nextEntries as TEntries;
}
/** Collects resolvable user aliases from one config entry, excluding wildcard entries. */
export function addAllowlistUserEntriesFromConfigEntry(target: Set<string>, entry: unknown): void {
if (!entry || typeof entry !== "object") {
return;
@@ -152,7 +147,6 @@ export function addAllowlistUserEntriesFromConfigEntry(target: Set<string>, entr
}
}
/** Logs compact allowlist resolution mapping output when there is anything to report. */
export function summarizeMapping(
label: string,
mapping: string[],

View File

@@ -1,30 +1,19 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
/** Source of the config entry selected for a channel target. */
export type ChannelMatchSource = "direct" | "parent" | "wildcard";
/** Match result retaining direct, parent, and wildcard candidates for diagnostics. */
export type ChannelEntryMatch<T> = {
/** Entry selected for the effective config result. */
entry?: T;
/** Config key for the selected entry. */
key?: string;
/** Wildcard fallback entry, retained even when a direct match wins. */
wildcardEntry?: T;
/** Config key for the wildcard fallback entry. */
wildcardKey?: string;
/** Parent conversation entry, retained when direct target matching falls back. */
parentEntry?: T;
/** Config key for the parent conversation entry. */
parentKey?: string;
/** Key that should be reported to callers as the effective match. */
matchKey?: string;
/** Precedence source that produced the effective match. */
matchSource?: ChannelMatchSource;
};
/** Copies match metadata onto a resolved config result. */
export function applyChannelMatchMeta<
TResult extends { matchKey?: string; matchSource?: ChannelMatchSource },
>(result: TResult, match: ChannelEntryMatch<unknown>): TResult {
@@ -35,7 +24,6 @@ export function applyChannelMatchMeta<
return result;
}
/** Resolves the matched entry into a config result while preserving match metadata. */
export function resolveChannelMatchConfig<
TEntry,
TResult extends { matchKey?: string; matchSource?: ChannelMatchSource },
@@ -46,7 +34,6 @@ export function resolveChannelMatchConfig<
return applyChannelMatchMeta(resolveEntry(match.entry), match);
}
/** Normalizes user-visible channel names into lowercase slug keys. */
export function normalizeChannelSlug(value: string): string {
return normalizeLowercaseStringOrEmpty(value)
.replace(/^#/, "")
@@ -54,12 +41,10 @@ export function normalizeChannelSlug(value: string): string {
.replace(/^-+|-+$/g, "");
}
/** Builds deduped key candidates while dropping blank/nullish entries. */
export function buildChannelKeyCandidates(...keys: Array<string | undefined | null>): string[] {
return normalizeUniqueSingleOrTrimmedStringList(keys);
}
/** Finds direct and wildcard entries without applying parent fallback precedence. */
export function resolveChannelEntryMatch<T>(params: {
entries?: Record<string, T>;
keys: string[];
@@ -76,15 +61,12 @@ export function resolveChannelEntryMatch<T>(params: {
break;
}
if (params.wildcardKey && Object.hasOwn(entries, params.wildcardKey)) {
// Keep wildcard metadata even when a direct entry exists so diagnostics can
// explain the fallback that would have applied.
match.wildcardEntry = entries[params.wildcardKey];
match.wildcardKey = params.wildcardKey;
}
return match;
}
/** Resolves channel config by direct match, normalized direct match, parent match, then wildcard. */
export function resolveChannelEntryMatchWithFallback<T>(params: {
entries?: Record<string, T>;
keys: string[];
@@ -104,15 +86,11 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
const normalizeKey = params.normalizeKey;
if (normalizeKey) {
// Normalized direct matching lets display names and ids converge before parent/wildcard
// fallback can broaden the selected config.
const normalizedKeys = params.keys.map((key) => normalizeKey(key)).filter(Boolean);
if (normalizedKeys.length > 0) {
for (const [entryKey, entry] of Object.entries(params.entries ?? {})) {
const normalizedEntry = normalizeKey(entryKey);
if (normalizedEntry && normalizedKeys.includes(normalizedEntry)) {
// Preserve the original configured key as matchKey; callers surface it
// in status/debug output instead of the normalized comparison key.
return {
...direct,
entry,
@@ -140,7 +118,6 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
};
}
if (normalizeKey) {
// Normalized parent keys keep thread/channel parent fallback consistent with direct keys.
const normalizedParentKeys = parentKeys.map((key) => normalizeKey(key)).filter(Boolean);
if (normalizedParentKeys.length > 0) {
for (const [entryKey, entry] of Object.entries(params.entries ?? {})) {
@@ -174,7 +151,6 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
return direct;
}
/** Resolves nested allowlists where an unconfigured outer/inner list means "no restriction". */
export function resolveNestedAllowlistDecision(params: {
outerConfigured: boolean;
outerMatched: boolean;
@@ -182,8 +158,6 @@ export function resolveNestedAllowlistDecision(params: {
innerMatched: boolean;
}): boolean {
if (!params.outerConfigured) {
// Unconfigured outer lists mean the whole nested policy is inactive; do not
// require an inner match until the outer scope has opted into restriction.
return true;
}
if (!params.outerMatched) {

View File

@@ -1,20 +1,13 @@
export type CommandAuthorizer = {
/** True when this authorizer has policy data for the current sender/context. */
configured: boolean;
/** True when the configured policy allows the control command. */
allowed: boolean;
};
/** Fallback policy used when access groups are disabled for a channel/account. */
export type CommandGatingModeWhenAccessGroupsOff = "allow" | "deny" | "configured";
/** Resolves command authorization from one or more configured policy sources. */
export function resolveCommandAuthorizedFromAuthorizers(params: {
/** True when configured access groups should be enforced. */
useAccessGroups: boolean;
/** Candidate authorizers; any configured allow grants access. */
authorizers: CommandAuthorizer[];
/** Fallback behavior when access groups are disabled. Defaults to allow. */
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): boolean {
const { useAccessGroups, authorizers } = params;
@@ -30,23 +23,16 @@ export function resolveCommandAuthorizedFromAuthorizers(params: {
if (!anyConfigured) {
return true;
}
// "configured" preserves legacy permissive behavior until a concrete authorizer exists.
return authorizers.some((entry) => entry.configured && entry.allowed);
}
return authorizers.some((entry) => entry.configured && entry.allowed);
}
/** Returns both command authorization and whether a text control command must be blocked. */
export function resolveControlCommandGate(params: {
/** True when configured access groups should be enforced. */
useAccessGroups: boolean;
/** Candidate authorizers checked before allowing text control commands. */
authorizers: CommandAuthorizer[];
/** True when text commands are enabled for this inbound surface. */
allowTextCommands: boolean;
/** True when the inbound text contains a recognized control command. */
hasControlCommand: boolean;
/** Fallback behavior when access groups are disabled. Defaults to allow. */
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): { commandAuthorized: boolean; shouldBlock: boolean } {
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
@@ -58,21 +44,13 @@ export function resolveControlCommandGate(params: {
return { commandAuthorized, shouldBlock };
}
/** Convenience wrapper for text command gates with primary and secondary authorizers. */
export function resolveDualTextControlCommandGate(params: {
/** True when configured access groups should be enforced. */
useAccessGroups: boolean;
/** True when the primary authorizer has policy data for this sender/context. */
primaryConfigured: boolean;
/** True when the primary authorizer allows the command. */
primaryAllowed: boolean;
/** True when the secondary authorizer has policy data for this sender/context. */
secondaryConfigured: boolean;
/** True when the secondary authorizer allows the command. */
secondaryAllowed: boolean;
/** True when the inbound text contains a recognized control command. */
hasControlCommand: boolean;
/** Fallback behavior when access groups are disabled. Defaults to allow. */
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): { commandAuthorized: boolean; shouldBlock: boolean } {
return resolveControlCommandGate({

View File

@@ -36,7 +36,6 @@ type ChannelPresenceSignal = {
source: ChannelPresenceSignalSource;
};
/** Returns true when a channel config section has operator data beyond an enabled toggle. */
export function hasMeaningfulChannelConfig(value: unknown): boolean {
if (!isRecord(value)) {
return false;
@@ -44,7 +43,6 @@ export function hasMeaningfulChannelConfig(value: unknown): boolean {
return Object.keys(value).some((key) => key !== "enabled");
}
/** Lists channel ids explicitly disabled in config, normalized for status/activation checks. */
export function listExplicitlyDisabledChannelIdsForConfig(cfg: OpenClawConfig): string[] {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
if (!channels) {
@@ -79,7 +77,6 @@ function listPersistedAuthStateChannelIds(options: ChannelPresenceOptions): read
if (options.discovery) {
return listBundledChannelIdsWithPersistedAuthState(options.discovery);
}
// Bundled persisted-auth metadata is process-stable; cache it outside hot status/plugin lookups.
if (persistedAuthStateChannelIds) {
return persistedAuthStateChannelIds;
}
@@ -105,7 +102,6 @@ function hasPersistedAuthState(params: {
});
}
/** Lists channel ids that appear configured through config, env vars, or persisted auth state. */
export function listPotentialConfiguredChannelIds(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
@@ -118,7 +114,6 @@ export function listPotentialConfiguredChannelIds(
);
}
/** Lists deduped configured-channel signals while preserving their source type. */
export function listPotentialConfiguredChannelPresenceSignals(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
@@ -197,7 +192,6 @@ function hasEnvConfiguredChannel(
);
}
/** Fast boolean check for any configured channel signal without materializing full plugin state. */
export function hasPotentialConfiguredChannels(
cfg: OpenClawConfig | null | undefined,
env: NodeJS.ProcessEnv = process.env,

View File

@@ -24,7 +24,6 @@ function shouldAppendId(id: string): boolean {
return false;
}
/** Resolves a concise conversation label for session lists, logs, and route summaries. */
export function resolveConversationLabel(ctx: MsgContext): string | undefined {
const explicit = normalizeOptionalString(ctx.ConversationLabel);
if (explicit) {
@@ -70,7 +69,5 @@ export function resolveConversationLabel(ctx: MsgContext): string | undefined {
if (base.startsWith("#") || base.startsWith("@")) {
return base;
}
// Numeric and address-like ids disambiguate generic group labels, but avoid appending them to
// explicit handles/channels or labels that already carry an id.
return `${base} id:${id}`;
}

View File

@@ -33,7 +33,6 @@ type ConversationResolutionSource =
| "inbound-bundled-plugin"
| "inbound-fallback";
/** Canonical conversation identity chosen for binding/spawn decisions. */
type ConversationResolution = {
canonical: {
channel: string;
@@ -46,7 +45,6 @@ type ConversationResolution = {
source: ConversationResolutionSource;
};
/** Raw command context used to resolve the conversation a command should bind to. */
export type ResolveCommandConversationResolutionInput = {
cfg: OpenClawConfig;
channel?: string | null;
@@ -65,7 +63,6 @@ export type ResolveCommandConversationResolutionInput = {
includePlacementHint?: boolean;
};
/** Raw inbound context used to resolve the conversation a message belongs to. */
type ResolveInboundConversationResolutionInput = {
cfg: OpenClawConfig;
channel?: string | null;
@@ -266,7 +263,6 @@ function resolveChannelTargetId(params: {
return target;
}
/** Convert command route facts into the provider hook context without inventing defaults. */
function buildThreadingContext(params: {
fallbackTo?: string;
originatingTo?: string;
@@ -286,7 +282,6 @@ function buildThreadingContext(params: {
};
}
/** Resolve where top-level thread bindings should attach for a channel. */
export function resolveChannelDefaultBindingPlacement(
rawChannel?: string | null,
): "current" | "child" | undefined {
@@ -299,7 +294,6 @@ export function resolveChannelDefaultBindingPlacement(
return pluginPlacement ?? resolveBundledChannelThreadBindingDefaultPlacement(channel);
}
/** Resolve command-originated conversation binding identity, preferring provider hooks first. */
export function resolveCommandConversationResolution(
params: ResolveCommandConversationResolutionInput,
): ConversationResolution | null {
@@ -368,7 +362,6 @@ export function resolveCommandConversationResolution(
return focusedResolution;
}
// Fallback order keeps explicit command/origin targets ahead of ambient context.
const baseConversationId =
resolveChannelTargetId({
channel,
@@ -408,7 +401,6 @@ export function resolveCommandConversationResolution(
});
}
/** Resolve inbound message conversation identity, respecting provider-owned rejection. */
export function resolveInboundConversationResolution(
params: ResolveInboundConversationResolutionInput,
): ConversationResolution | null {
@@ -445,7 +437,6 @@ export function resolveInboundConversationResolution(
plugin,
});
if (providerResolution || providerConversation === null) {
// A null provider result is an explicit rejection; do not reinterpret it generically.
return providerResolution;
}
@@ -462,7 +453,6 @@ export function resolveInboundConversationResolution(
plugin,
});
if (artifactResolution || artifactConversation === null) {
// Bundled artifact resolvers keep the same stop-on-null contract as provider hooks.
return artifactResolution;
}

View File

@@ -14,9 +14,7 @@ import {
import type { ChannelId } from "./plugins/types.public.js";
export type { AccessGroupMembershipResolver } from "../plugin-sdk/access-groups.js";
/** Runtime callbacks needed by the legacy direct-DM authorizer bridge. */
export type DirectDmCommandAuthorizationRuntime = {
/** Returns whether a raw body should run command authorization. */
shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean;
/** @deprecated Command authorization is resolved by channel ingress. Kept for runtime injection compatibility. */
resolveCommandAuthorizedFromAuthorizers?: (params: {
@@ -28,18 +26,14 @@ export type DirectDmCommandAuthorizationRuntime = {
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export type ResolvedInboundDirectDmAccess = {
/** DM access decision after configured and pairing-store allowlists are merged. */
access: {
decision: "allow" | "block" | "pairing";
reasonCode: DmGroupAccessReasonCode;
reason: string;
effectiveAllowFrom: string[];
};
/** Whether command authorization was applicable to this inbound body. */
shouldComputeAuth: boolean;
/** Whether the sender matched the effective DM allowlist used for command checks. */
senderAllowedForCommands: boolean;
/** Command authorization result when applicable. */
commandAuthorized: boolean | undefined;
};
@@ -52,17 +46,11 @@ function toLegacyDmReasonCode(reasonCode: string): DmGroupAccessReasonCode {
case DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED:
return reasonCode;
default:
// Legacy direct-DM consumers only understand the compact DM reason enum.
// Unknown ingress reasons fail closed as not-allowlisted.
return DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED;
}
}
/**
* Resolves legacy direct-DM access and command authorization for channel adapters.
*
* @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`.
*/
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export async function resolveInboundDirectDmAccessWithRuntime(params: {
cfg: OpenClawConfig;
channel: ChannelId;
@@ -91,8 +79,6 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
readStore: params.readStoreAllowFrom,
})
: [];
// Expand configured and pairing-store allowlists independently so diagnostics and command
// authorization use the same effective entries as the legacy DM access decision.
const [allowFrom, effectiveStoreAllowFrom] = await Promise.all([
expandAllowFromWithAccessGroups({
cfg: params.cfg,
@@ -126,9 +112,6 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
params.senderId,
access.effectiveAllowFrom,
);
// Older channel runtimes may not inject the shared command authorizer. Keep
// the local allowlist decision as the fallback so legacy adapters retain their
// pre-access-groups behavior.
const commandAuthorized = shouldComputeAuth
? (params.runtime.resolveCommandAuthorizedFromAuthorizers?.({
useAccessGroups: params.cfg.commands?.useAccessGroups !== false,
@@ -155,12 +138,7 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
};
}
/**
* Builds the pre-crypto direct-DM authorizer used before encrypted payload
* parsing can hand off to normal channel ingress.
*
* @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`.
*/
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function createPreCryptoDirectDmAuthorizer(params: {
resolveAccess: (
senderId: string,
@@ -185,8 +163,6 @@ export function createPreCryptoDirectDmAuthorizer(params: {
return "allow";
}
if (access.decision === "pairing") {
// Pairing challenges are optional because some adapters only need to signal pairing state
// while another layer sends the challenge text.
if (params.issuePairingChallenge) {
await params.issuePairingChallenge({
senderId: input.senderId,
@@ -195,8 +171,6 @@ export function createPreCryptoDirectDmAuthorizer(params: {
}
return "pairing";
}
// Block notifications stay callback-only so pre-crypto adapters can log or
// metric the drop without forcing a reply on hostile or unauthenticated DMs.
params.onBlocked?.({
senderId: input.senderId,
reason: access.reason,

View File

@@ -1,15 +1,10 @@
import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion";
export type DirectDmPreCryptoGuardPolicy = {
/** Provider message kinds accepted before decrypted content is available. */
allowedKinds: readonly number[];
/** Maximum future timestamp skew accepted before rejecting a message. */
maxFutureSkewSec: number;
/** Maximum encrypted payload bytes accepted before crypto work starts. */
maxCiphertextBytes: number;
/** Maximum decrypted plaintext bytes accepted after crypto succeeds. */
maxPlaintextBytes: number;
/** Per-sender and global limits applied before expensive crypto/decode work. */
rateLimit: {
windowMs: number;
maxPerSenderPerWindow: number;

View File

@@ -1,30 +1,18 @@
import { resolveTimerTimeoutMs } from "../shared/number-coercion.js";
/** Throttled draft streaming loop for preview send/edit updates. */
export type DraftStreamLoop = {
/** Queue the latest draft text and schedule a send/edit when allowed by throttle state. */
update: (text: string) => void;
/** Immediately flush the latest pending text, waiting for any in-flight send first. */
flush: () => Promise<void>;
/** Stop future sends and clear any pending timer/text. */
stop: () => void;
/** Clear pending text without changing throttle or in-flight state. */
resetPending: () => void;
/** Reset throttle timing and cancel the pending timer. */
resetThrottleWindow: () => void;
/** Wait for the current send/edit promise without flushing pending text. */
waitForInFlight: () => Promise<void>;
};
/** Creates a throttled stream loop that serializes draft preview send/edit calls. */
export function createDraftStreamLoop(params: {
/** Minimum delay between successful send/edit attempts. */
throttleMs: number;
/** Stop predicate checked before every flush iteration. */
isStopped: () => boolean;
/** Sends or edits the current draft text; false keeps the text pending for retry. */
sendOrEditStreamMessage: (text: string) => Promise<void | boolean>;
/** Background flush error sink used to avoid unhandled promise rejections. */
onBackgroundFlushError?: (err: unknown) => void;
}): DraftStreamLoop {
const throttleMs = resolveTimerTimeoutMs(params.throttleMs, 0, 0);
@@ -69,8 +57,6 @@ export function createDraftStreamLoop(params: {
throw err;
}
if (sent === false) {
// A false result means the adapter declined this update without throwing; keep it pending
// so a later explicit flush can retry the same latest text.
pendingText = text;
return;
}

View File

@@ -8,49 +8,34 @@ import {
} from "../auto-reply/inbound-debounce.js";
import type { OpenClawConfig } from "../config/types.js";
/** Returns whether an inbound text event may be debounced before agent dispatch. */
export function shouldDebounceTextInbound(params: {
/** Raw text or command body from the inbound event. */
text: string | null | undefined;
/** Config used for command detection and debounce duration. */
cfg: OpenClawConfig;
/** Media-bearing events bypass debounce so attachments are processed promptly. */
hasMedia?: boolean;
/** Command parser options used to detect control commands. */
commandOptions?: CommandNormalizeOptions;
/** Explicit per-channel opt-out. */
allowDebounce?: boolean;
}): boolean {
if (params.allowDebounce === false) {
return false;
}
if (params.hasMedia) {
// Media events can carry upload/download side effects; dispatch them
// immediately so attachment processing is not delayed behind text batching.
return false;
}
const text = normalizeOptionalString(params.text) ?? "";
if (!text) {
return false;
}
// Control commands must run immediately; debouncing them can reorder operator actions.
return !isControlCommandMessage(text, params.cfg, params.commandOptions);
}
/** Creates a channel-specific inbound debouncer using config-derived timing. */
export function createChannelInboundDebouncer<T>(
params: Omit<InboundDebounceCreateParams<T>, "debounceMs"> & {
/** Config used to resolve channel debounce settings. */
cfg: OpenClawConfig;
/** Channel id whose debounce settings should be applied. */
channel: string;
/** Test/runtime override that bypasses config-derived debounce duration. */
debounceMsOverride?: number;
},
): {
/** Resolved debounce duration passed into the debouncer. */
debounceMs: number;
/** Debouncer instance scoped to the channel. */
debouncer: ReturnType<typeof createInboundDebouncer<T>>;
} {
const debounceMs = resolveInboundDebounceMs({
@@ -58,8 +43,6 @@ export function createChannelInboundDebouncer<T>(
channel: params.channel,
overrideMs: params.debounceMsOverride,
});
// Resolve timing once when the channel monitor is created; per-message checks
// only decide whether an event is debounceable, not what timer to use.
const { cfg: _cfg, channel: _channel, debounceMsOverride: _override, ...rest } = params;
const debouncer = createInboundDebouncer<T>({
debounceMs,

View File

@@ -52,7 +52,6 @@ type BuildAccessFacts = Omit<AccessFacts, "commands"> & {
commands?: Partial<NonNullable<AccessFacts["commands"]>>;
};
/** Normalized channel facts used to build the legacy templating context for one inbound event. */
export type BuildChannelInboundEventContextParams = {
channel: string;
accountId?: string;
@@ -88,7 +87,6 @@ type UntrustedStructuredContextEntries = NonNullable<
FinalizedMsgContext["UntrustedStructuredContext"]
>;
/** Finalized context shape consumed by auto-reply templating and channel turn dispatch. */
export type BuiltChannelInboundEventContext = FinalizedMsgContext & {
Body: string;
BodyForAgent: string;
@@ -158,7 +156,6 @@ function keepSupplementalContext(params: {
});
}
/** Apply visibility policy to quote, forwarded, and thread supplemental context. */
export function filterChannelInboundSupplementalContext(params: {
supplemental?: SupplementalContextFacts;
contextVisibility?: ContextVisibilityMode;
@@ -197,7 +194,6 @@ export function filterChannelInboundSupplementalContext(params: {
};
}
/** Filter only quoted-message context while preserving the shared visibility policy. */
export function filterChannelInboundQuoteContext(
contextVisibility: ContextVisibilityMode | undefined,
quote: SupplementalContextFacts["quote"] | undefined,
@@ -254,7 +250,6 @@ function resolveChannelInboundSupplementalForFinalizer(params: {
const suppressSelfQuoteBody = params.suppressSelfQuoteBody ?? true;
const suppressSelfQuoteMedia = params.suppressSelfQuoteMedia ?? true;
const finalizeQuote = (quoteMedia?: readonly InboundMediaFacts[] | null) => {
// Self-quote media is already present on the current message; appending it would duplicate attachments.
if (!(selfQuote && suppressSelfQuoteMedia)) {
media.push(...(quoteMedia ?? []));
}
@@ -386,7 +381,6 @@ export function finalizeChannelInboundContext<T extends Record<string, unknown>>
return isPromiseLike(prepared) ? prepared.then(finish) : finish(prepared);
}
/** Prefer explicit authorization, then legacy authorizer arrays for older channel callers. */
function resolveAccessFactsCommandAuthorized(
access: BuildAccessFacts | undefined,
): boolean | undefined {
@@ -431,7 +425,6 @@ function resolveUntrustedStructuredContext(params: {
return entries.length > 0 ? entries : undefined;
}
/** Build command-turn metadata exposed to agents from normalized inbound command facts. */
function resolveChannelCommandContext(params: {
command?: CommandFacts;
commandTurn?: CommandTurnContext;
@@ -456,7 +449,6 @@ function resolveChannelCommandContext(params: {
});
}
/** Build and finalize the full inbound event context passed into channel turns. */
export function buildChannelInboundEventContext(
params: BuildChannelInboundEventContextAsyncParams,
): Promise<BuiltChannelInboundEventContext>;

View File

@@ -1,32 +1,20 @@
/** Minimal logger shape accepted by shared channel diagnostics helpers. */
export type LogFn = (message: string) => void;
/** Logs a dropped inbound message using the shared channel/target format. */
export function logInboundDrop(params: {
/** Logger supplied by the channel runtime. */
log: LogFn;
/** Human-readable channel id included at the start of the line. */
channel: string;
/** Compact drop reason suitable for low-volume operator logs. */
reason: string;
/** Optional conversation or recipient target used to disambiguate drops. */
target?: string;
}): void {
const target = params.target ? ` target=${params.target}` : "";
params.log(`${params.channel}: drop ${params.reason}${target}`);
}
/** Logs non-fatal typing feedback failures without interrupting reply delivery. */
export function logTypingFailure(params: {
/** Logger supplied by the channel runtime. */
log: LogFn;
/** Human-readable channel id included at the start of the line. */
channel: string;
/** Optional conversation or recipient target used to disambiguate the failure. */
target?: string;
/** Typing action that failed when the channel reports start/stop separately. */
action?: "start" | "stop";
/** Original channel/API error to stringify for diagnostics. */
error: unknown;
}): void {
const target = params.target ? ` target=${params.target}` : "";
@@ -34,15 +22,10 @@ export function logTypingFailure(params: {
params.log(`${params.channel} typing${action} failed${target}: ${String(params.error)}`);
}
/** Logs non-fatal acknowledgement cleanup failures after message handling continues. */
export function logAckFailure(params: {
/** Logger supplied by the channel runtime. */
log: LogFn;
/** Human-readable channel id included at the start of the line. */
channel: string;
/** Optional conversation or recipient target used to disambiguate the failure. */
target?: string;
/** Original channel/API error to stringify for diagnostics. */
error: unknown;
}): void {
const target = params.target ? ` target=${params.target}` : "";

View File

@@ -32,38 +32,24 @@ export type MentionGateWithBypassResult = MentionGateResult & {
};
export type InboundImplicitMentionKind =
/** Message replied directly to a bot-authored message. */
| "reply_to_bot"
/** Message quoted bot-authored content. */
| "quoted_bot"
/** Message arrived in a thread where the bot is already a participant. */
| "bot_thread_participant"
/** Channel-native mention signal normalized by legacy callers. */
| "native";
export type InboundMentionFacts = {
/** True when the channel can reliably detect explicit mentions. */
canDetectMention: boolean;
/** True when the inbound message explicitly mentioned the bot. */
wasMentioned: boolean;
/** True when the message mentioned anyone, used to avoid command bypass ambiguity. */
hasAnyMention?: boolean;
/** Channel-derived implicit mention reasons that may satisfy mention gating. */
implicitMentionKinds?: readonly InboundImplicitMentionKind[];
};
export type InboundMentionPolicy = {
/** True for group-like conversations where mention gating applies. */
isGroup: boolean;
/** True when the channel/account requires bot mentions before responding. */
requireMention: boolean;
/** Optional allowlist limiting which implicit mention reasons count as mentions. */
allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[];
/** True when text control commands are enabled for this surface. */
allowTextCommands: boolean;
/** True when the inbound text contains a recognized control command. */
hasControlCommand: boolean;
/** True when access policy allows the sender to run the control command. */
commandAuthorized: boolean;
};
@@ -71,9 +57,7 @@ export type InboundMentionPolicy = {
export type ResolveInboundMentionDecisionFlatParams = InboundMentionFacts & InboundMentionPolicy;
export type ResolveInboundMentionDecisionNestedParams = {
/** Observed mention facts from the inbound message. */
facts: InboundMentionFacts;
/** Channel/account policy used to interpret the mention facts. */
policy: InboundMentionPolicy;
};
@@ -82,11 +66,8 @@ export type ResolveInboundMentionDecisionParams =
| ResolveInboundMentionDecisionNestedParams;
export type InboundMentionDecision = MentionGateResult & {
/** True when at least one allowed implicit mention reason matched. */
implicitMention: boolean;
/** Deduped implicit mention reasons accepted by policy. */
matchedImplicitMentionKinds: InboundImplicitMentionKind[];
/** True when an authorized group control command bypassed explicit mention gating. */
shouldBypassMention: boolean;
};
@@ -187,12 +168,10 @@ function normalizeMentionDecisionParams(
};
}
/** Resolves whether mention policy allows, skips, or command-bypasses one inbound message. */
export function resolveInboundMentionDecision(
params: ResolveInboundMentionDecisionParams,
): InboundMentionDecision {
const { facts, policy } = normalizeMentionDecisionParams(params);
// Authorized text commands may bypass mention gating only when the message names no one else.
const shouldBypassMention =
policy.isGroup &&
policy.requireMention &&

View File

@@ -48,7 +48,6 @@ function resolveProviderMentionPatternsPolicy(
return isMentionPatternsPolicyConfig(policy) ? policy : undefined;
}
/** Resolve provider-scoped mention-pattern gating, with deny entries winning over allow entries. */
export function resolveMentionPatternPolicy(
params: ResolveMentionPatternPolicyParams,
): ResolvedMentionPatternPolicy {

View File

@@ -8,7 +8,6 @@ import type {
ResolvedIngressAllowlist,
} from "./types.js";
/** Returns the highest-priority access-group failure reason for one resolved allowlist. */
export function allowlistFailureReason(
allowlist: ResolvedIngressAllowlist,
): IngressReasonCode | null {
@@ -24,7 +23,6 @@ export function allowlistFailureReason(
return null;
}
/** Builds diagnostics that expose counts and opaque ids without raw allowlist values. */
export function redactedAllowlistDiagnostics(
allowlist: ResolvedIngressAllowlist,
reasonCode: IngressReasonCode,
@@ -74,7 +72,6 @@ function mergeResolvedAllowlists(
};
}
/** Removes dangerous mutable identifier matches unless policy explicitly enables them. */
export function applyMutableIdentifierPolicy(
allowlist: ResolvedIngressAllowlist,
policy: ChannelIngressPolicyInput,
@@ -112,7 +109,6 @@ export function applyMutableIdentifierPolicy(
};
}
/** Resolves the effective group sender allowlist after fallback and route sender policy. */
export function effectiveGroupSenderAllowlist(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;

View File

@@ -275,7 +275,6 @@ export function decideChannelIngress(
commandGate: commandGate({ state, policy: { ...policy, command: undefined } }),
})
: null;
// Pre-sender activation cannot depend on command auth, so command facts are deliberately absent.
if (activationBeforeSender) {
gates.push(activationBeforeSender);
if (activationBeforeSender.effect === "skip") {

View File

@@ -14,14 +14,12 @@ function accessGroupNames(entries: readonly (string | number)[]): string[] {
);
}
/** Extracts every referenced access-group name from raw allowlist entry groups. */
export function allReferencedAccessGroupNames(
entries: Array<readonly (string | number)[]>,
): string[] {
return uniqueStrings(entries.flatMap((entryGroup) => accessGroupNames(entryGroup)));
}
/** Normalizes direct entries while preserving access-group tokens for later expansion. */
export async function normalizeEffectiveEntries(params: {
adapter: ChannelIngressAdapter;
accountId: string;
@@ -47,7 +45,6 @@ export async function normalizeEffectiveEntries(params: {
]);
}
/** Resolves dynamic access-group facts before the state builder expands static sender groups. */
export async function resolveRuntimeAccessGroupMembershipFacts(params: {
input: ResolveChannelMessageIngressParams;
channelId: ChannelIngressChannelId;

View File

@@ -95,7 +95,6 @@ function adapterEntry(params: {
};
}
/** Creates the normalization/matching adapter used by the ingress decision engine. */
export function createIdentityAdapter(
identity: ChannelIngressIdentityDescriptor,
): ChannelIngressAdapter {
@@ -145,7 +144,6 @@ export function createIdentityAdapter(
const matchedEntryIds = entries
.filter((entry) => {
const fallback = entry.value === "*" || subjectKeys.has(identityMatchKey(entry));
// Custom identity hooks may widen or narrow matches; undefined preserves default matching.
return identity.matchEntry?.({ subject, entry, context }) ?? fallback;
})
.map((entry) => entry.opaqueEntryId);
@@ -157,7 +155,6 @@ export function createIdentityAdapter(
};
}
/** Converts raw channel sender ids into redaction-aware subject identifiers. */
export function createIdentitySubject(
identity: ChannelIngressIdentityDescriptor,
input: ChannelIngressIdentitySubjectInput,

View File

@@ -463,7 +463,6 @@ function projectRouteAccess(params: {
const senderBlock = params.ingress.graph.gates.find(
(entry) => entry.phase === "sender" && entry.effect === "block-dispatch",
);
// Route sender replacement moves the route's user-facing reason onto the sender gate.
if (routeSenderReplacement && senderBlock) {
return {
allowed: false,
@@ -616,7 +615,6 @@ export async function resolveChannelMessageIngress(
const rawGroupAllowFrom = normalizeStringEntries(params.groupAllowFrom ?? []);
const normalizeEffective = (entries: readonly (string | number)[], context: "dm" | "group") =>
normalizeEffectiveEntries({ adapter, accountId: params.accountId, entries, context });
// Keep raw allowlists for redacted state/graph evidence while normalized copies drive matching.
const [normalizedAllowFrom, normalizedStoreAllowFrom, normalizedGroupAllowFrom] =
await Promise.all([
normalizeEffective(rawAllowFrom, "dm"),

View File

@@ -34,7 +34,6 @@ function senderGate(params: {
};
}
/** Evaluates direct-message sender policy against configured and pairing-store allowlists. */
export function senderGateForDirect(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;
@@ -71,7 +70,6 @@ export function senderGateForDirect(params: {
return block("dm_policy_disabled");
}
if (params.policy.dmPolicy === "open") {
// Open DMs still require an explicit wildcard or match; they skip pairing-store fallback only.
if (dm.hasWildcard) {
return allow("dm_policy_open");
}
@@ -105,7 +103,6 @@ export function senderGateForDirect(params: {
return block(reasonCode);
}
/** Evaluates group/channel sender policy after route sender overrides are applied. */
export function senderGateForGroup(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;
@@ -149,7 +146,6 @@ export function senderGateForGroup(params: {
return block(allowlistFailureReason(group) ?? "group_policy_not_allowlisted");
}
/** Converts sender blocks into ignored gates for event modes that authorize elsewhere. */
export function applyEventAuthModeToSenderGate(params: {
state: ChannelIngressState;
senderGate: AccessGraphGate;

View File

@@ -317,7 +317,6 @@ async function resolveIngressAllowlist(params: {
async function resolveRouteFacts(
input: ChannelIngressStateInput,
): Promise<ResolvedRouteGateFacts[]> {
// Deterministic route order keeps the access graph stable across config object iteration.
const routeFacts = [...(input.routeFacts ?? [])].toSorted(
(left, right) => left.precedence - right.precedence || left.id.localeCompare(right.id),
);

View File

@@ -10,8 +10,6 @@ function hasMediaPayload(
if (payload.mediaUrl?.trim()) {
return true;
}
// Multi-media payloads may contain empty optional slots; only non-empty URLs require the media
// durable-final capability.
return (
Array.isArray(payload.mediaUrls) &&
payload.mediaUrls.some((url) => typeof url === "string" && url.trim().length > 0)
@@ -42,8 +40,6 @@ export function deriveDurableFinalDeliveryRequirements(
);
setRequired(requirements, "thread", params.threadId != null);
setRequired(requirements, "silent", params.silent);
// Sending hooks are required by default because durable final delivery must preserve adapter
// lifecycle hooks unless the caller explicitly opted out.
setRequired(requirements, "messageSendingHooks", params.messageSendingHooks !== false);
setRequired(requirements, "payload", params.payloadTransport);
setRequired(requirements, "batch", params.batch);

View File

@@ -21,9 +21,7 @@ export type DurableFinalCapabilityProofMap = Partial<
>;
export type DurableFinalCapabilityProofResult = {
/** Capability checked in canonical capability order. */
capability: DurableFinalDeliveryCapability;
/** Whether the capability was declared and proved by the adapter test. */
status: "verified" | "not_declared";
};
@@ -46,27 +44,20 @@ export type ChannelMessageReceiveAckPolicyProofMap = Partial<
>;
export type LivePreviewFinalizerCapabilityProofResult = {
/** Finalizer capability checked in canonical capability order. */
capability: LivePreviewFinalizerCapability;
/** Whether the capability was declared and proved by the adapter test. */
status: "verified" | "not_declared";
};
export type ChannelMessageLiveCapabilityProofResult = {
/** Live-message capability checked in canonical capability order. */
capability: ChannelMessageLiveCapability;
/** Whether the capability was declared and proved by the adapter test. */
status: "verified" | "not_declared";
};
export type ChannelMessageReceiveAckPolicyProofResult = {
/** Receive acknowledgement policy checked in canonical policy order. */
policy: ChannelMessageReceiveAckPolicy;
/** Whether the policy was declared and proved by the adapter test. */
status: "verified" | "not_declared";
};
/** List declared durable-final capabilities in canonical order. */
export function listDeclaredDurableFinalCapabilities(
capabilities: DurableFinalDeliveryRequirementMap | undefined,
): DurableFinalDeliveryCapability[] {
@@ -75,7 +66,6 @@ export function listDeclaredDurableFinalCapabilities(
);
}
/** List declared live-preview finalizer capabilities in canonical order. */
export function listDeclaredLivePreviewFinalizerCapabilities(
capabilities: LivePreviewFinalizerCapabilityMap | undefined,
): LivePreviewFinalizerCapability[] {
@@ -84,14 +74,12 @@ export function listDeclaredLivePreviewFinalizerCapabilities(
);
}
/** List declared live-message capabilities in canonical order. */
export function listDeclaredChannelMessageLiveCapabilities(
capabilities: Partial<Record<ChannelMessageLiveCapability, boolean>> | undefined,
): ChannelMessageLiveCapability[] {
return channelMessageLiveCapabilities.filter((capability) => capabilities?.[capability] === true);
}
/** List receive acknowledgement policies, falling back from supported policies to the default. */
export function listDeclaredReceiveAckPolicies(
receive: ChannelMessageAdapterShape["receive"] | undefined,
): ChannelMessageReceiveAckPolicy[] {
@@ -103,7 +91,6 @@ export function listDeclaredReceiveAckPolicies(
return channelMessageReceiveAckPolicies.filter((policy) => declared.includes(policy));
}
/** Run one proof for every declared durable-final capability and fail on missing proofs. */
export async function verifyDurableFinalCapabilityProofs(params: {
adapterName: string;
capabilities?: DurableFinalDeliveryRequirementMap;
@@ -127,7 +114,6 @@ export async function verifyDurableFinalCapabilityProofs(params: {
return results;
}
/** Run one proof for every declared live-preview finalizer capability. */
export async function verifyLivePreviewFinalizerCapabilityProofs(params: {
adapterName: string;
capabilities?: LivePreviewFinalizerCapabilityMap;
@@ -151,7 +137,6 @@ export async function verifyLivePreviewFinalizerCapabilityProofs(params: {
return results;
}
/** Run one proof for every declared live-message capability. */
export async function verifyChannelMessageLiveCapabilityProofs(params: {
adapterName: string;
capabilities?: Partial<Record<ChannelMessageLiveCapability, boolean>>;
@@ -175,7 +160,6 @@ export async function verifyChannelMessageLiveCapabilityProofs(params: {
return results;
}
/** Run one proof for every declared receive acknowledgement policy. */
export async function verifyChannelMessageReceiveAckPolicyProofs(params: {
adapterName: string;
receive?: ChannelMessageAdapterShape["receive"];
@@ -200,7 +184,6 @@ export async function verifyChannelMessageReceiveAckPolicyProofs(params: {
return results;
}
/** Verify durable-final capabilities declared on a full channel message adapter. */
export async function verifyChannelMessageAdapterCapabilityProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "durableFinal">;
@@ -213,7 +196,6 @@ export async function verifyChannelMessageAdapterCapabilityProofs(params: {
});
}
/** Verify receive acknowledgement policies declared on a full channel message adapter. */
export async function verifyChannelMessageReceiveAckPolicyAdapterProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "receive">;
@@ -226,7 +208,6 @@ export async function verifyChannelMessageReceiveAckPolicyAdapterProofs(params:
});
}
/** Verify live-preview finalizer capabilities declared on a full channel message adapter. */
export async function verifyChannelMessageLiveFinalizerProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "live">;
@@ -239,7 +220,6 @@ export async function verifyChannelMessageLiveFinalizerProofs(params: {
});
}
/** Verify live-message capabilities declared on a full channel message adapter. */
export async function verifyChannelMessageLiveCapabilityAdapterProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "live">;

View File

@@ -67,22 +67,17 @@ export type DurableInboundReceiveReleaseOptions = {
/** Durable receive journal facade used by channel receive pipelines. */
export type DurableInboundReceiveJournal<TPayload, TMetadata, TCompletedMetadata> = {
/** Records a platform event unless a pending/completed duplicate already exists. */
accept(
id: string,
payload: TPayload,
options?: DurableInboundReceiveAcceptOptions<TMetadata>,
): Promise<DurableInboundReceiveAcceptResult<TPayload, TMetadata, TCompletedMetadata>>;
/** Returns pending records in deterministic receive-time order. */
pending(): Promise<Array<DurableInboundReceivePendingRecord<TPayload, TMetadata>>>;
/** Moves an inbound event from pending to completed duplicate-suppression state. */
complete(
id: string,
options?: DurableInboundReceiveCompleteOptions<TCompletedMetadata>,
): Promise<void>;
/** Requeues a pending event after a failed dispatch attempt. */
release(id: string, options?: DurableInboundReceiveReleaseOptions): Promise<boolean>;
/** Deletes pending state without creating a completed tombstone. */
deletePending(id: string): Promise<boolean>;
};
@@ -163,8 +158,6 @@ export function createDurableInboundReceiveJournal<
return { kind: "pending", duplicate: true, record: pending };
}
// A delete/complete race can make the pending lookup miss after registerIfAbsent lost; check
// completion before retrying so a completed duplicate never re-enters pending state.
const completedAfterPendingRace = await options.completedStore.lookup(key);
if (completedAfterPendingRace) {
return { kind: "completed", duplicate: true, record: completedAfterPendingRace };
@@ -189,8 +182,6 @@ export function createDurableInboundReceiveJournal<
const entries = await options.pendingStore.entries();
const records: Array<DurableInboundReceivePendingRecord<TPayload, TMetadata>> = [];
for (const entry of entries) {
// Tombstones win over stale pending entries; clean them up while reading to keep callers
// from dispatching a duplicate event that has already completed.
if (await options.completedStore.lookup(entry.key)) {
await options.pendingStore.delete(entry.key);
continue;

View File

@@ -1,3 +1,8 @@
/**
* Shared inbound reply dispatch helpers for channel message adapters and
* deprecated SDK compatibility facades.
*/
import { withReplyDispatcher } from "../../auto-reply/dispatch.js";
import type { GetReplyOptions } from "../../auto-reply/get-reply-options.types.js";
import {
@@ -52,16 +57,12 @@ type ReplyOptionsWithoutModelSelected = Omit<
type RecordInboundSessionFn = typeof import("../session.js").recordInboundSession;
type ReplyDispatchFromConfigOptions = Omit<GetReplyOptions, "onBlockReply">;
/** Parameters for running a raw inbound channel event through the shared turn pipeline. */
export type ChannelInboundEventRunnerParams<
TRaw,
TDispatchResult = DispatchFromConfigResult,
> = RunChannelTurnParams<TRaw, TDispatchResult>;
/** Prepared turn shape kept for legacy inbound-reply naming. */
export type PreparedInboundReply<TDispatchResult> = PreparedChannelTurn<TDispatchResult>;
/** Assembled dispatch context kept for legacy inbound-reply naming. */
export type AssembledInboundReply = AssembledChannelTurn;
/** Turn result shape kept for legacy inbound-reply naming. */
export type InboundReplyDispatchResult<TDispatchResult> = ChannelTurnResult<TDispatchResult>;
/** Run an already prepared inbound reply through shared session-record + dispatch ordering. */
@@ -147,8 +148,6 @@ export async function dispatchReplyFromConfigWithSettledDispatcher(params: {
return await withReplyDispatcher({
dispatcher: params.dispatcher,
onSettled: params.onSettled,
// withReplyDispatcher owns the finally path so streamed/block dispatchers
// release typing, buffers, and channel resources even when dispatch throws.
run: () =>
dispatchReplyFromConfig({
ctx: params.ctxPayload,
@@ -198,33 +197,19 @@ export function buildInboundReplyDispatchBase(params: {
type BuildInboundReplyDispatchBaseParams = Parameters<typeof buildInboundReplyDispatchBase>[0];
type RecordChannelMessageReplyDispatchParams = {
/** Config used to resolve agent/session/reply settings for the inbound turn. */
cfg: OpenClawConfig;
/** Channel id that owns the inbound reply turn. */
channel: string;
/** Optional account scope for multi-account channel adapters. */
accountId?: string;
/** Agent selected by route resolution before dispatch starts. */
agentId: string;
/** Stable session key used for inbound session history. */
routeSessionKey: string;
/** Store path used by the reply dispatcher for session state. */
storePath: string;
/** Finalized inbound message context passed to prompt templating. */
ctxPayload: FinalizedMsgContext;
/** Session recorder that must run before reply dispatch. */
recordInboundSession: RecordInboundSessionFn;
/** Buffered reply dispatcher used to produce tool/block/final reply deliveries. */
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
/** Legacy outbound delivery callback used when durable message delivery is unavailable. */
deliver: (payload: OutboundReplyPayload) => Promise<void>;
/** Durable delivery options, or false to force the legacy deliver callback. */
durable?: false | DurableInboundReplyDeliveryOptions;
/** Error sink for session-record failures that should not skip dispatch. */
onRecordError: (err: unknown) => void;
/** Error sink for reply delivery failures, tagged by reply kind. */
onDispatchError: (err: unknown, info: { kind: string }) => void;
/** Reply options forwarded without block-dispatcher/model-selection overrides. */
replyOptions?: ReplyOptionsWithoutModelSelected;
};
@@ -291,11 +276,11 @@ export async function recordChannelMessageReplyDispatch(
dispatchReplyWithBufferedBlockDispatcher: params.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
preparePayload: (payload) =>
payload && typeof payload === "object" ? normalizeOutboundReplyPayload(payload) : {},
payload && typeof payload === "object"
? normalizeOutboundReplyPayload(payload)
: {},
deliver: async (payload, info) => {
if (params.durable) {
// Durable delivery owns normalized message lifecycle results; fall
// back only when the adapter reports that this payload was unhandled.
const durable = await deliverInboundReplyWithMessageSendContext({
cfg: params.cfg,
channel: params.channel,
@@ -311,8 +296,6 @@ export async function recordChannelMessageReplyDispatch(
return durable.delivery;
}
}
// Compatibility callers still own legacy delivery when durable routing
// is disabled or cannot handle this specific normalized payload.
return await params.deliver(payload as OutboundReplyPayload);
},
onError: params.onDispatchError,

View File

@@ -114,7 +114,6 @@ export type ChannelIngressQueueEnqueueResult<TPayload, TMetadata, TCompletedMeta
/** Durable FIFO-ish ingress queue with claims, duplicate detection, and retention pruning. */
export type ChannelIngressQueue<TPayload, TMetadata = unknown, TCompletedMetadata = unknown> = {
/** Accepts a platform event id once and reports existing pending/claimed/tombstone duplicates. */
enqueue(
id: string,
payload: TPayload,
@@ -124,47 +123,38 @@ export type ChannelIngressQueue<TPayload, TMetadata = unknown, TCompletedMetadat
laneKey?: string;
},
): Promise<ChannelIngressQueueEnqueueResult<TPayload, TMetadata, TCompletedMetadata>>;
/** Lists unclaimed pending events in receive order unless id ordering is requested. */
listPending(options?: {
limit?: number | "all";
orderBy?: "received" | "id";
}): Promise<Array<ChannelIngressQueueRecord<TPayload, TMetadata>>>;
/** Lists currently claimed events for recovery and worker diagnostics. */
listClaims(): Promise<Array<ChannelIngressQueueClaim<TPayload, TMetadata>>>;
/** Claims the next available event while optionally skipping lane keys already in flight. */
claimNext(options?: {
ownerId?: string;
blockedLaneKeys?: Iterable<string>;
staleMs?: number;
}): Promise<ChannelIngressQueueClaim<TPayload, TMetadata> | null>;
/** Claims one pending event by id for targeted replay or repair work. */
claim(
id: string,
options?: { ownerId?: string },
): Promise<ChannelIngressQueueClaim<TPayload, TMetadata> | null>;
/** Converts a pending/claimed event into a completed tombstone for duplicate suppression. */
complete(
idOrClaim: string | ChannelIngressQueueClaimRef,
options?: { metadata?: TCompletedMetadata; completedAt?: number },
): Promise<boolean>;
/** Releases a pending/claimed event for retry and records attempt/error metadata. */
release(
idOrClaim: string | ChannelIngressQueueClaimRef,
options?: { lastError?: string; releasedAt?: number },
): Promise<boolean>;
/** Converts a pending/claimed event into a failed tombstone for diagnostics and dedupe. */
fail(
idOrClaim: string | ChannelIngressQueueClaimRef,
options: { reason: string; message?: string; failedAt?: number },
): Promise<boolean>;
/** Deletes a pending/claimed event without leaving a duplicate-suppression tombstone. */
delete(
idOrClaim:
| string
| ChannelIngressQueueRecord<TPayload, TMetadata>
| ChannelIngressQueueClaimRef,
): Promise<boolean>;
/** Releases stale claims after an optional caller veto for live worker ownership checks. */
recoverStaleClaims(options?: {
staleMs?: number;
now?: number;
@@ -172,7 +162,6 @@ export type ChannelIngressQueue<TPayload, TMetadata = unknown, TCompletedMetadat
claim: ChannelIngressQueueClaim<TPayload, TMetadata>,
) => boolean | Promise<boolean>;
}): Promise<number>;
/** Removes expired or over-limit pending/completed/failed rows while preserving protected ids. */
prune(options?: ChannelIngressQueuePruneOptions): Promise<number>;
};
@@ -292,8 +281,6 @@ function idFrom(idOrRecord: string | { id: string }): string {
function claimTokenFrom(
idOrClaim: string | { id: string; claim?: { token: string } },
): string | null {
// Mutations on claimed rows must carry the claim token so stale workers cannot complete or drop
// events after another worker recovered and claimed the same id.
return typeof idOrClaim === "string" ? null : (idOrClaim.claim?.token ?? null);
}
@@ -799,8 +786,6 @@ export function createChannelIngressQueue<
const batchSize = 500;
const protectedSet = new Set(protectIds);
while (true) {
// Keep the newest rows by updated time; delete overflow in bounded batches so a large
// queue cannot build an unbounded SQL parameter list.
const rowsToDelete = executeSqliteQuerySync(
tx.db,
kysely

View File

@@ -3,15 +3,10 @@ export type { LiveMessagePhase, LiveMessageState } from "./types.js";
/** Mutable draft preview handle used before a live message is finalized or discarded. */
export type LivePreviewFinalizerDraft<TId> = {
/** Flush pending preview updates before reading or editing the draft id. */
flush: () => Promise<void>;
/** Return the provider id for the current draft preview, if one exists. */
id: () => TId | undefined;
/** Prevent later preview edits before finalizing in place. */
seal?: () => Promise<void>;
/** Drop queued preview work while keeping the visible draft available for fallback cleanup. */
discardPending?: () => Promise<void>;
/** Remove all local/provider draft preview state after final delivery. */
clear: () => Promise<void>;
};
@@ -31,23 +26,17 @@ export type LivePreviewFinalizerResult<TPayload> = {
/** Adapter contract for channels that can edit a draft preview into the final message. */
export type FinalizableLivePreviewAdapter<TPayload, TId, TEdit> = {
draft?: LivePreviewFinalizerDraft<TId>;
/** Convert the final payload into a provider-native edit, or return undefined to fall back. */
buildFinalEdit: (payload: TPayload) => TEdit | undefined;
/** Apply the final edit to the draft preview id. */
editFinal: (id: TId, edit: TEdit) => Promise<void>;
/** Map draft ids to the final platform id when the provider changes ids after edit. */
resolveFinalizedId?: (id: TId, edit: TEdit) => TId | undefined;
/** Build the receipt used after finalizing a preview in place. */
createPreviewReceipt?: (id: TId, edit: TEdit) => MessageReceipt;
onPreviewFinalized?: (
id: TId,
receipt: MessageReceipt,
liveState: LiveMessageState<TPayload>,
) => Promise<void> | void;
/** Extract media or other payload pieces that still need normal delivery after final edit. */
buildSupplementalPayload?: (payload: TPayload) => TPayload | undefined;
deliverSupplemental?: (payload: TPayload) => Promise<boolean | void>;
/** Decide whether an ambiguous preview edit error should fall back or retain the preview. */
handlePreviewEditError?: (params: {
error: unknown;
id: TId;
@@ -213,7 +202,6 @@ export async function deliverFinalizableLivePreview<TPayload, TId, TEdit>(params
}
if (params.draft.discardPending) {
// Final edit was impossible; discard pending preview work before sending a normal final reply.
await params.draft.discardPending();
} else {
await params.draft.clear();

View File

@@ -19,33 +19,26 @@ const defaultManualReceiveAdapter = {
supportedAckPolicies: ["manual"],
} as const satisfies ChannelMessageReceiveAdapterShape;
/** Legacy send result accepted by outbound bridge methods before receipt normalization. */
/** Send result accepted from legacy outbound bridge methods before receipt normalization. */
export type ChannelMessageOutboundBridgeResult = MessageReceiptSourceResult & {
/** Already-normalized receipt from adapters that can describe multipart sends themselves. */
receipt?: MessageReceipt;
/** Adapter-level id retained for older callers that do not return a full receipt. */
messageId?: string;
};
/** Legacy outbound adapter shape bridged into the channel message adapter contract. */
export type ChannelMessageOutboundBridgeAdapter<TConfig = unknown> = {
/** Durable final-send capabilities declared by older outbound implementations. */
deliveryCapabilities?: {
durableFinal?: DurableFinalDeliveryRequirementMap;
};
/** Text-only send hook used when the channel exposes a narrow text API. */
sendText?: (
ctx: ChannelMessageSendTextContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
/** Media send hook used for file/image/audio sends with optional caption text. */
sendMedia?: (
ctx: ChannelMessageSendMediaContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
/** Structured payload hook used by channels that consume rich reply payloads directly. */
sendPayload?: (
ctx: ChannelMessageSendPayloadContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
/** Poll send hook used when the platform has a native poll endpoint. */
sendPoll?: (
ctx: ChannelMessageSendPollContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
@@ -53,21 +46,14 @@ export type ChannelMessageOutboundBridgeAdapter<TConfig = unknown> = {
/** Options for building a message adapter from legacy outbound send functions. */
export type CreateChannelMessageAdapterFromOutboundParams<TConfig = unknown> = {
/** Stable adapter id surfaced through channel message capability listings. */
id?: string;
/** Legacy outbound implementation to wrap. */
outbound: ChannelMessageOutboundBridgeAdapter<TConfig>;
/** Capability override when wrapper ownership, not legacy outbound, declares guarantees. */
capabilities?: DurableFinalDeliveryRequirementMap;
/** Optional live-preview adapter metadata to preserve on the wrapped shape. */
live?: ChannelMessageLiveAdapterShape;
/** Optional receive adapter metadata; defaults to manual ack ownership for legacy sends. */
receive?: ChannelMessageReceiveAdapterShape;
};
function resolveResultMessageId(result: ChannelMessageOutboundBridgeResult): string | undefined {
// Prefer explicit and normalized receipt ids before provider-specific ids so follow-up edits
// target the same primary platform message that receipt normalization selected.
return (
result.messageId ??
result.receipt?.primaryPlatformMessageId ??
@@ -90,8 +76,6 @@ function toMessageSendResult(
replyToId?: string | null;
},
): ChannelMessageSendResult {
// Poll APIs often return card-like receipts from older senders; normalize the part kind so
// durable capability checks and recovery classify the message by the API that sent it.
const receipt = result.receipt
? params.normalizeReceiptKind
? {
@@ -118,8 +102,6 @@ function toMessageSendResult(
function resolvePayloadReceiptKind(
ctx: ChannelMessageSendPayloadContext<unknown>,
): MessageReceiptPartKind {
// Structured payload sends can collapse multiple content shapes into one hook; preserve the
// most specific durable-recovery kind rather than treating every payload as a generic card.
if (
ctx.payload.audioAsVoice &&
(ctx.mediaUrl || ctx.payload.mediaUrl || ctx.payload.mediaUrls?.length)

View File

@@ -81,7 +81,6 @@ export function createMessageReceiptFromOutboundResults(params: {
const platformMessageIds: string[] = [];
for (const result of params.results) {
if (hasNestedReceiptData(result.receipt)) {
// Keep adapter-supplied id order before adding part ids; downstream edit/delete uses the first id.
appendUnique(platformMessageIds, result.receipt.primaryPlatformMessageId);
for (const platformMessageId of result.receipt.platformMessageIds) {
appendUnique(platformMessageIds, platformMessageId);

View File

@@ -11,31 +11,18 @@ export type MessageAckState = "pending" | "acked" | "nacked";
/** Mutable receive context passed through durable inbound message processing. */
export type MessageReceiveContext<TMessage = unknown> = {
/** Provider-native inbound message id. */
id: string;
/** Channel id that received the inbound message. */
channel: string;
/** Optional account scope for multi-account channels. */
accountId?: string;
/** Provider-native or normalized inbound message payload. */
message: TMessage;
/** Policy controlling when the message should be acknowledged. */
ackPolicy: MessageAckPolicy;
/** Current acknowledgement state. */
ackState: MessageAckState;
/** Timestamp recorded when ack succeeds. */
ackedAt?: number;
/** Human-readable nack error when acknowledgement fails. */
nackErrorMessage?: string;
/** Timestamp when core accepted the inbound message for processing. */
receivedAt: number;
/** Cancellation signal for downstream receive processing. */
signal: AbortSignal;
/** Returns whether the current policy wants an ack after the supplied pipeline stage. */
shouldAckAfter(stage: MessageAckStage): boolean;
/** Marks the message acknowledged and runs the adapter ack hook at most once. */
ack(): Promise<void>;
/** Marks the message negatively acknowledged and records the normalized failure message. */
nack(error: unknown): Promise<void>;
};
@@ -46,8 +33,6 @@ export function shouldAckMessageAfterStage(
policy: MessageAckPolicy,
stage: MessageAckStage,
): boolean {
// Ack stages intentionally map one-to-one to policies; "manual" never auto-acks so channel
// adapters can own platform-specific acknowledgement timing themselves.
switch (policy) {
case "after_receive_record":
return stage === "receive_record";

View File

@@ -16,7 +16,6 @@ function collectMediaUrls(payload: ReplyPayload): string[] {
.filter((url): url is string => Boolean(url));
}
/** Builds the replayable content-shape summary for one rendered reply payload. */
function createRenderedMessageBatchPlanItem(
payload: ReplyPayload,
index: number,

View File

@@ -45,13 +45,10 @@ export type ChannelReplyPipeline = ReplyPrefixOptions & {
export type CreateChannelReplyPipelineParams = {
cfg: Parameters<typeof createReplyPrefixOptions>[0]["cfg"];
agentId: string;
/** Channel id used for prefix policy and lazy plugin reply transforms. */
channel?: string;
/** Account id passed to channel-owned reply transforms. */
accountId?: string;
typing?: CreateTypingCallbacksParams;
typingCallbacks?: TypingCallbacks;
/** Caller override that runs instead of the channel plugin transform. */
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
};
@@ -65,8 +62,7 @@ export function createChannelReplyPipeline(
let plugin: ReturnType<typeof getLoadedChannelPluginForRead> | undefined;
let pluginTransformResolved = false;
const resolvePluginTransform = () => {
// Load the channel plugin lazily and at most once so reply-pipeline
// construction stays cheap for hot turn paths that never send a reply.
// Load the channel plugin lazily so reply-pipeline construction stays cheap for hot turn paths.
if (pluginTransformResolved) {
return plugin?.messaging?.transformReplyPayload;
}
@@ -77,25 +73,13 @@ export function createChannelReplyPipeline(
const transformReplyPayload = params.transformReplyPayload
? params.transformReplyPayload
: channelId
? (payload: ReplyPayload) => {
// Channel-owned transforms run after prefix/typing setup, but an
// explicit caller transform above bypasses registry lookup entirely.
return (
resolvePluginTransform()?.({
payload,
cfg: params.cfg,
accountId: params.accountId,
}) ?? payload
);
}
? (payload: ReplyPayload) =>
resolvePluginTransform()?.({
payload,
cfg: params.cfg,
accountId: params.accountId,
}) ?? payload
: undefined;
const typingCallbacks = params.typingCallbacks
? params.typingCallbacks
: params.typing
? createTypingCallbacks(params.typing)
: undefined;
// Preserve prebuilt callbacks for channels with custom lifecycle hooks;
// otherwise synthesize callbacks only when typing config is provided.
return {
...createReplyPrefixOptions({
cfg: params.cfg,
@@ -104,6 +88,10 @@ export function createChannelReplyPipeline(
accountId: params.accountId,
}),
...(transformReplyPayload ? { transformReplyPayload } : {}),
...(typingCallbacks ? { typingCallbacks } : {}),
...(params.typingCallbacks
? { typingCallbacks: params.typingCallbacks }
: params.typing
? { typingCallbacks: createTypingCallbacks(params.typing) }
: {}),
};
}

View File

@@ -30,15 +30,11 @@ export type DurableMessageBatchSendParams = Omit<
DeliverOutboundPayloadsParams,
"abortSignal" | "onDeliveryIntent" | "payloads" | "queuePolicy"
> & {
/** Reply payloads to render and send as one logical durable batch. */
payloads: ReplyPayload[];
/** Retry attempt number surfaced through the send context. */
attempt?: number;
/** Preferred cancellation signal for durable delivery. */
signal?: AbortSignal;
/** @deprecated Use `signal`. */
abortSignal?: AbortSignal;
/** Receipt from a previous preview/send attempt, when retrying. */
previousReceipt?: MessageReceipt;
};
@@ -50,17 +46,13 @@ export type DurableMessageFailureStage = "platform_send" | "queue" | "unknown";
export type DurableMessagePayloadDeliveryOutcome =
| {
/** Payload index within the rendered batch. */
index: number;
status: "sent";
/** Raw platform results produced for this payload. */
results: OutboundDeliveryResult[];
}
| {
/** Payload index within the rendered batch. */
index: number;
status: "suppressed";
/** Why no visible platform message was sent. */
reason: DurableMessageSuppressionReason;
hookEffect?: {
cancelReason?: string;
@@ -68,13 +60,10 @@ export type DurableMessagePayloadDeliveryOutcome =
};
}
| {
/** Payload index within the rendered batch. */
index: number;
status: "failed";
error: unknown;
/** True when the platform may already have accepted a prior payload. */
sentBeforeError: boolean;
/** Phase where delivery failed or became ambiguous. */
stage: DurableMessageFailureStage;
};
@@ -142,7 +131,6 @@ function toDurablePayloadOutcomes(
export type DurableMessageSendContextParams = DurableMessageBatchSendParams & {
durability?: Exclude<MessageDurabilityPolicy, "disabled">;
/** Live preview state carried across render/send/edit/commit hooks. */
preview?: LiveMessageState<ReplyPayload>;
onPreviewUpdate?: (
rendered: RenderedMessageBatch<ReplyPayload>,
@@ -338,7 +326,6 @@ export async function withDurableMessageSendContext<T>(
const result = await run(ctx);
return result;
} catch (error: unknown) {
// Cleanup failures are logged inside ctx.fail so callers still observe the original send error.
await ctx.fail(error);
throw error;
}

View File

@@ -10,15 +10,10 @@ export type DurableMessageSendState =
/** Recovery record for one durable outbound message intent. */
export type DurableMessageStateRecord = {
/** Replayable outbound intent captured before or during platform send. */
intent: DurableMessageSendIntent;
/** Current recovery classification for this durable send. */
state: DurableMessageSendState;
/** Platform receipt when the send is known to have completed. */
receipt?: MessageReceipt;
/** Last state transition time in milliseconds. */
updatedAt: number;
/** Human-readable failure summary for operator-visible status. */
errorMessage?: string;
};

View File

@@ -43,25 +43,15 @@ export type DurableFinalDeliveryPayloadShape = {
/** Raw platform result shape normalized into a message receipt. */
export type MessageReceiptSourceResult = {
/** Provider/channel id that produced the platform result. */
channel?: string;
/** Generic platform message id returned by most send APIs. */
messageId?: string;
/** Chat-scoped id used by some channel APIs as the sent message id. */
chatId?: string;
/** Channel-scoped id returned by workspace-style APIs. */
channelId?: string;
/** Room-scoped id returned by room-based providers. */
roomId?: string;
/** Conversation-scoped id returned by conversation-first providers. */
conversationId?: string;
/** WhatsApp/JID-style destination id used as a fallback receipt key. */
toJid?: string;
/** Poll id returned when the send created a platform poll. */
pollId?: string;
/** Platform send timestamp when the adapter exposes it. */
timestamp?: number;
/** Provider-native metadata retained for reconciliation/debugging. */
meta?: Record<string, unknown>;
};
@@ -77,39 +67,24 @@ export type MessageReceiptPartKind =
/** One platform message produced by a logical outbound send. */
export type MessageReceiptPart = {
/** Platform message id for this concrete sent part. */
platformMessageId: string;
/** Logical content kind that produced this part. */
kind: MessageReceiptPartKind;
/** Stable order within the logical send. */
index: number;
/** Thread/topic id used by the platform for this part. */
threadId?: string;
/** Platform message id this part replied to. */
replyToId?: string;
/** Raw adapter result retained when built from legacy send output. */
raw?: MessageReceiptSourceResult;
};
/** Normalized receipt for all platform messages that make up a logical send. */
export type MessageReceipt = {
/** Preferred platform id for edits/deletes when a logical send has multiple parts. */
primaryPlatformMessageId?: string;
/** Unique platform ids in send order. */
platformMessageIds: string[];
/** Per-part receipts for multipart sends. */
parts: MessageReceiptPart[];
/** Thread/topic id shared by the logical send when available. */
threadId?: string;
/** Reply target shared by the logical send when available. */
replyToId?: string;
/** Provider token required to edit the sent message. */
editToken?: string;
/** Provider token required to delete the sent message. */
deleteToken?: string;
/** Millisecond timestamp when core considers the logical send complete. */
sentAt: number;
/** Raw adapter results used to construct this normalized receipt. */
raw?: readonly MessageReceiptSourceResult[];
};
@@ -435,40 +410,24 @@ export type DurableFinalRequirementExtras = DurableFinalDeliveryRequirementMap;
/** Inputs used to derive durable final-delivery requirements for a planned send. */
export type DeriveDurableFinalDeliveryRequirementsParams = {
payload: DurableFinalDeliveryPayloadShape;
/** Reply target means the adapter needs reply-to durability support. */
replyToId?: string | null;
/** Thread target means the adapter needs thread durability support. */
threadId?: string | number | null;
/** Silent sends require adapters to declare silent final-delivery support. */
silent?: boolean;
/** Whether lifecycle hooks around sends must be preserved by durable delivery. */
messageSendingHooks?: boolean;
/** Whether the planned send uses the structured payload adapter path. */
payloadTransport?: boolean;
/** Whether multiple rendered payloads must be delivered as one durable logical batch. */
batch?: boolean;
/** Whether unknown platform-send outcomes require adapter reconciliation. */
reconcileUnknownSend?: boolean;
/** Whether post-send success hooks must run before the send is considered durable. */
afterSendSuccess?: boolean;
/** Whether commit hooks must run before the final receipt is trusted. */
afterCommit?: boolean;
/** Caller-supplied capabilities that extend the built-in derivation rules. */
extraCapabilities?: DurableFinalRequirementExtras;
};
/** Stable intent record for a durable outbound message send. */
export type DurableMessageSendIntent<TPayload = unknown> = {
/** Queue-stable id for this logical outbound send. */
id: string;
/** Channel id that owns the outbound send. */
channel: string;
/** Provider-native destination target. */
to: string;
/** Optional account scope used by multi-account channels. */
accountId?: string;
/** Durable policy selected after disabled sends have been filtered out. */
durability: Exclude<MessageDurabilityPolicy, "disabled">;
/** Last rendered payload batch, retained for retry/reconciliation. */
renderedBatch?: RenderedMessageBatch<TPayload>;
};

View File

@@ -46,8 +46,6 @@ function resolveProviderEntry(
): Record<string, string> | undefined {
const normalized =
normalizeMessageChannel(channel) ?? normalizeOptionalLowercaseString(channel) ?? "";
// Accept both canonical channel ids and legacy/case-varied config keys so
// existing modelByChannel entries survive channel id normalization changes.
return (
modelByChannel?.[normalized] ??
modelByChannel?.[
@@ -72,9 +70,6 @@ function buildChannelCandidates(
const groupId = normalizeOptionalString(params.groupId);
const rawParentConversation = parseRawSessionConversationRef(params.parentSessionKey);
const channelPlugin = normalizedChannel ? getChannelPlugin(normalizedChannel) : undefined;
// Some channels encode parent conversations differently from generic session
// keys; let the loaded plugin add candidates before falling back to bundled
// parsing so per-channel thread model overrides still match.
const parentOverrideFallbacks =
channelPlugin?.conversationBindings?.buildModelOverrideParentCandidates?.({
parentConversationId: rawParentConversation?.rawId,
@@ -125,8 +120,6 @@ function buildGenericParentOverrideCandidates(sessionKey: string | null | undefi
return [];
}
const { baseSessionKey, threadId } = parseThreadSessionSuffix(raw.rawId);
// Thread child sessions inherit from their base session key; non-thread
// parents keep the raw conversation id as the direct override candidate.
return buildChannelKeyCandidates(threadId ? baseSessionKey : raw.rawId);
}
@@ -185,8 +178,6 @@ export function resolveChannelModelOverride(
parentSessionKey: params.parentSessionKey,
});
if (directMatch) {
// Direct group/session matches win before richer conversation fallback keys,
// preserving the old flat `modelByChannel[channel][groupId]` behavior.
return {
channel: normalizeMessageChannel(channel) ?? normalizeOptionalLowercaseString(channel) ?? "",
model: directMatch.model,
@@ -197,8 +188,6 @@ export function resolveChannelModelOverride(
const { keys, parentKeys } = buildChannelCandidates(params);
if (keys.length === 0 && parentKeys.length === 0) {
// With no concrete conversation identity, only the channel wildcard can
// apply; avoid treating an empty key as a real configured override.
const wildcardModel = normalizeOptionalString(providerEntries["*"]);
if (wildcardModel) {
return {

View File

@@ -9,7 +9,6 @@ export type ResolveNativeCommandSessionTargetsParams = {
lowercaseSessionKey?: boolean;
};
/** Resolve the session key pair used to execute native commands in bound or ad hoc sessions. */
export function resolveNativeCommandSessionTargets(
params: ResolveNativeCommandSessionTargetsParams,
) {

View File

@@ -1,10 +1,8 @@
/** Predicate for channel actions that can be disabled at base or account scope. */
export type ActionGate<T extends Record<string, boolean | undefined>> = (
key: keyof T,
defaultValue?: boolean,
) => boolean;
/** Creates an action gate where account settings override base channel defaults. */
export function createAccountActionGate<T extends Record<string, boolean | undefined>>(params: {
baseActions?: T;
accountActions?: T;
@@ -12,7 +10,6 @@ export function createAccountActionGate<T extends Record<string, boolean | undef
return (key, defaultValue = true) => {
const accountValue = params.accountActions?.[key];
if (accountValue !== undefined) {
// Explicit false is meaningful; only undefined falls through to the broader scope.
return accountValue;
}
const baseValue = params.baseActions?.[key];

View File

@@ -12,7 +12,6 @@ import {
} from "../../routing/session-key.js";
import type { ChannelAccountSnapshot } from "./types.core.js";
/** Creates account id listing/default helpers for one channel config namespace. */
export function createAccountListHelpers(
channelKey: string,
options?: {
@@ -31,7 +30,6 @@ export function createAccountListHelpers(
}
const channel = cfg.channels?.[channelKey] as Record<string, unknown> | undefined;
for (const key of options?.implicitDefaultAccount?.channelKeys ?? []) {
// Root-level credentials imply a default account even when named accounts also exist.
if (hasConfiguredAccountValue(channel?.[key])) {
return true;
}
@@ -95,7 +93,6 @@ export function createAccountListHelpers(
return { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId };
}
/** Returns whether a config/env value should count as an account being configured. */
export function hasConfiguredAccountValue(value: unknown): boolean {
if (typeof value === "string") {
return value.trim().length > 0;
@@ -103,7 +100,6 @@ export function hasConfiguredAccountValue(value: unknown): boolean {
return value !== undefined && value !== null;
}
/** Combines configured, extra, and implicit account ids into a sorted unique list. */
export function listCombinedAccountIds(params: {
configuredAccountIds: Iterable<string>;
additionalAccountIds?: Iterable<string>;
@@ -132,7 +128,6 @@ export function listCombinedAccountIds(params: {
return [...ids].toSorted((a, b) => a.localeCompare(b));
}
/** Chooses the default account id from listed accounts and optional configured preference. */
export function resolveListedDefaultAccountId(params: {
accountIds: readonly string[];
configuredDefaultAccountId?: string | undefined;
@@ -158,7 +153,6 @@ export function resolveListedDefaultAccountId(params: {
return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID;
}
/** Merges channel-level config with an account override, omitting account container keys. */
export function mergeAccountConfig<TConfig extends Record<string, unknown>>(params: {
channelConfig: TConfig | undefined;
accountConfig: Partial<TConfig> | undefined;
@@ -186,7 +180,6 @@ export function mergeAccountConfig<TConfig extends Record<string, unknown>>(para
accountValue != null &&
!Array.isArray(accountValue)
) {
// Selected nested objects merge shallowly so account overrides can tweak one subkey.
(merged as Record<string, unknown>)[key] = {
...(baseValue as Record<string, unknown>),
...(accountValue as Record<string, unknown>),
@@ -196,7 +189,6 @@ export function mergeAccountConfig<TConfig extends Record<string, unknown>>(para
return merged;
}
/** Resolves an account entry and returns the merged channel/account config. */
export function resolveMergedAccountConfig<TConfig extends Record<string, unknown>>(params: {
channelConfig: TConfig | undefined;
accounts: Record<string, Partial<TConfig>> | undefined;
@@ -222,7 +214,6 @@ type AccountSnapshotInput = {
name?: string | null | undefined;
};
/** Builds a normalized account status snapshot for status/catalog surfaces. */
export function describeAccountSnapshot(params: {
account: AccountSnapshotInput;
configured?: boolean | undefined;
@@ -237,7 +228,6 @@ export function describeAccountSnapshot(params: {
};
}
/** Builds a webhook-mode account snapshot with optional extra status metadata. */
export function describeWebhookAccountSnapshot(params: {
account: AccountSnapshotInput;
configured?: boolean | undefined;

View File

@@ -24,7 +24,6 @@ import type {
import type { ConfiguredBindingConsumer } from "./configured-binding-consumers.js";
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
/** Resolves ACP runtime defaults from the owner agent when it uses the ACP runtime. */
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
acpAgentId?: string;
mode?: string;
@@ -46,7 +45,6 @@ function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgen
};
}
/** Resolves cwd for configured ACP bindings from explicit or default agent workspace config. */
function resolveConfiguredBindingWorkspaceCwd(params: {
cfg: OpenClawConfig;
agentId: string;
@@ -66,7 +64,6 @@ function resolveConfiguredBindingWorkspaceCwd(params: {
return undefined;
}
/** Builds the normalized ACP binding spec that backs records and session keys. */
function buildConfiguredAcpSpec(params: {
channel: string;
accountId: string;
@@ -92,7 +89,6 @@ function buildConfiguredAcpSpec(params: {
};
}
/** Builds a target factory for ACP binding config, merging runtime defaults with overrides. */
function buildAcpTargetFactory(params: {
cfg: OpenClawConfig;
binding: ConfiguredBindingRuleConfig;
@@ -148,7 +144,6 @@ function buildAcpTargetFactory(params: {
};
}
/** Configured-binding consumer for ACP targets. */
export const acpConfiguredBindingConsumer: ConfiguredBindingConsumer = {
id: "acp",
supports: (binding) => binding.type === "acp",

View File

@@ -19,7 +19,6 @@ import type {
StatefulBindingTargetSessionResult,
} from "./stateful-target-drivers.js";
/** Converts ACP session metadata or configured binding specs into a stateful target descriptor. */
function toAcpStatefulBindingTargetDescriptor(params: {
cfg: OpenClawConfig;
sessionKey: string;
@@ -68,7 +67,6 @@ function toAcpStatefulBindingTargetDescriptor(params: {
};
}
/** Ensures the ACP configured binding behind a stateful target is ready. */
async function ensureAcpTargetReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
@@ -91,7 +89,6 @@ async function ensureAcpTargetReady(params: {
});
}
/** Ensures the ACP configured binding has a live target session. */
async function ensureAcpTargetSession(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
@@ -110,7 +107,6 @@ async function ensureAcpTargetSession(params: {
});
}
/** Resets an ACP stateful target through the gateway session authority. */
async function resetAcpTargetInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
@@ -132,7 +128,6 @@ async function resetAcpTargetInPlace(params: {
};
}
/** Stateful target driver for configured ACP bindings. */
export const acpStatefulBindingTargetDriver: StatefulBindingTargetDriver = {
id: "acp",
ensureReady: ensureAcpTargetReady,

View File

@@ -4,7 +4,6 @@ type ReactionToolContext = {
currentMessageId?: string | number;
};
/** Resolves the reaction target message id from explicit args or current tool context. */
export function resolveReactionMessageId(params: {
args: Record<string, unknown>;
toolContext?: ReactionToolContext;

View File

@@ -4,14 +4,12 @@ type TokenSourcedAccount = {
tokenSource?: string | null;
};
/** Filters out accounts explicitly configured with tokenSource "none". */
export function listTokenSourcedAccounts<TAccount extends TokenSourcedAccount>(
accounts: readonly TAccount[],
): TAccount[] {
return accounts.filter((account) => account.tokenSource !== "none");
}
/** Creates an action gate that allows an action when any account gate allows it. */
export function createUnionActionGate<TAccount, TKey extends string>(
accounts: readonly TAccount[],
createGate: (account: TAccount) => OptionalDefaultGate<TKey>,

View File

@@ -3,22 +3,17 @@ import type { ChannelApprovalKind } from "../../infra/approval-types.js";
import type { ExecApprovalRequest } from "../../infra/exec-approvals.js";
import type { PluginApprovalRequest } from "../../infra/plugin-approvals.js";
/** Native approval surface where a channel can deliver action controls. */
export type ChannelApprovalNativeSurface = "origin" | "approver-dm";
/** Channel target for a native approval message. */
export type ChannelApprovalNativeTarget = {
to: string;
threadId?: string | number | null;
};
/** Preferred native approval surface when more than one is available. */
export type ChannelApprovalNativeDeliveryPreference = ChannelApprovalNativeSurface | "both";
/** Approval request types that can be rendered natively by a channel. */
export type ChannelApprovalNativeRequest = ExecApprovalRequest | PluginApprovalRequest;
/** Capability summary used before deciding where to render native approval controls. */
export type ChannelApprovalNativeDeliveryCapabilities = {
enabled: boolean;
preferredSurface: ChannelApprovalNativeDeliveryPreference;
@@ -27,7 +22,6 @@ export type ChannelApprovalNativeDeliveryCapabilities = {
notifyOriginWhenDmOnly?: boolean;
};
/** Channel-owned native approval routing and capability adapter. */
export type ChannelApprovalNativeAdapter = {
describeDeliveryCapabilities: (params: {
cfg: OpenClawConfig;

View File

@@ -1,17 +1,12 @@
import type { ChannelApprovalAdapter, ChannelApprovalCapability } from "./types.adapters.js";
import type { ChannelPlugin } from "./types.plugin.js";
/** Returns the raw approval capability advertised by a channel plugin. */
export function resolveChannelApprovalCapability(
plugin?: Pick<ChannelPlugin, "approvalCapability"> | null,
): ChannelApprovalCapability | undefined {
return plugin?.approvalCapability;
}
/**
* Converts a channel approval capability into an adapter only when it exposes at
* least one executable approval surface.
*/
export function resolveChannelApprovalAdapter(
plugin?: Pick<ChannelPlugin, "approvalCapability"> | null,
): ChannelApprovalAdapter | undefined {
@@ -25,8 +20,6 @@ export function resolveChannelApprovalAdapter(
!capability.render &&
!capability.native
) {
// A setup-description-only capability is useful metadata, but it is not an
// adapter the runtime can invoke for approval handling.
return undefined;
}
return {

View File

@@ -1,7 +1,6 @@
import type { ChannelConfiguredBindingProvider } from "./types.adapters.js";
import type { ChannelPlugin } from "./types.plugin.js";
/** Returns a plugin's configured-binding provider surface when present. */
export function resolveChannelConfiguredBindingProvider(
plugin:
| Pick<ChannelPlugin, "bindings">

View File

@@ -15,7 +15,6 @@ import type { ConfiguredBindingResolution } from "./binding-types.js";
const CONFIGURED_BINDING_ROUTE_READY_TIMEOUT_MS = 30_000;
/** Result of resolving a configured binding before a route is finalized. */
export type ConfiguredBindingRouteResult = {
bindingResolution: ConfiguredBindingResolution | null;
route: ResolvedAgentRoute;
@@ -23,7 +22,6 @@ export type ConfiguredBindingRouteResult = {
boundAgentId?: string;
};
/** Result of resolving an existing runtime conversation binding. */
export type RuntimeConversationBindingRouteResult = {
bindingRecord: SessionBindingRecord | null;
route: ResolvedAgentRoute;
@@ -68,7 +66,6 @@ function isPluginOwnedRuntimeBindingRecord(record: SessionBindingRecord | null):
);
}
/** Rewrites a route to a configured stateful binding target when one matches. */
export function resolveConfiguredBindingRoute(
params: {
cfg: OpenClawConfig;
@@ -89,7 +86,6 @@ export function resolveConfiguredBindingRoute(
const boundSessionKey = bindingResolution.statefulTarget.sessionKey.trim();
if (!boundSessionKey) {
// Empty target session keys keep the matched binding for diagnostics but cannot route traffic.
return {
bindingResolution,
route: params.route,
@@ -114,7 +110,6 @@ export function resolveConfiguredBindingRoute(
};
}
/** Rewrites a route to an existing runtime binding when the binding is core-owned. */
export function resolveRuntimeConversationBindingRoute(
params: {
route: ResolvedAgentRoute;
@@ -143,7 +138,6 @@ export function resolveRuntimeConversationBindingRoute(
getSessionBindingService().touch(bindingRecord.bindingId);
if (isPluginOwnedRuntimeBindingRecord(bindingRecord)) {
// Plugin-owned bindings are bookkeeping records; the plugin already owns final delivery.
return {
bindingRecord,
route: params.route,
@@ -168,7 +162,6 @@ export function resolveRuntimeConversationBindingRoute(
};
}
/** Bounds configured binding readiness checks so channel routing cannot hang indefinitely. */
export async function ensureConfiguredBindingRouteReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution | null;

View File

@@ -9,7 +9,6 @@ import {
resolveStatefulBindingTargetBySessionKey,
} from "./stateful-target-drivers.js";
/** Ensures the configured binding target driver is loaded and ready for routing. */
export async function ensureConfiguredBindingTargetReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution | null;
@@ -35,7 +34,6 @@ export async function ensureConfiguredBindingTargetReady(params: {
});
}
/** Resets a stateful binding target in place when the owning driver supports it. */
export async function resetConfiguredBindingTargetInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
@@ -65,7 +63,6 @@ export async function resetConfiguredBindingTargetInPlace(params: {
});
}
/** Ensures the configured binding target has an active routed session. */
export async function ensureConfiguredBindingTargetSession(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;

View File

@@ -10,14 +10,10 @@ import type {
} from "./types.adapters.js";
import type { ChannelId } from "./types.public.js";
/** Runtime conversation identity used by configured binding lookup. */
export type ConfiguredBindingConversation = ConversationRef;
/** Channel id type used after configured binding channel normalization. */
export type ConfiguredBindingChannel = ChannelId;
/** Raw binding config rule before channel-specific compilation. */
export type ConfiguredBindingRuleConfig = AgentBinding;
/** Stateful target descriptor emitted by a configured binding target factory. */
export type StatefulBindingTargetDescriptor = {
kind: "stateful";
driverId: string;
@@ -26,13 +22,11 @@ export type StatefulBindingTargetDescriptor = {
label?: string;
};
/** Persisted binding record plus the stateful target it materializes. */
export type ConfiguredBindingRecordResolution = {
record: SessionBindingRecord;
statefulTarget: StatefulBindingTargetDescriptor;
};
/** Channel/consumer-owned factory that materializes configured binding targets. */
export type ConfiguredBindingTargetFactory = {
driverId: string;
materialize: (params: {
@@ -41,7 +35,6 @@ export type ConfiguredBindingTargetFactory = {
}) => ConfiguredBindingRecordResolution;
};
/** Channel-compiled binding rule ready for conversation matching. */
export type CompiledConfiguredBinding = {
channel: ConfiguredBindingChannel;
accountPattern?: string;
@@ -53,7 +46,6 @@ export type CompiledConfiguredBinding = {
targetFactory: ConfiguredBindingTargetFactory;
};
/** Full configured binding resolution used by routing and target drivers. */
export type ConfiguredBindingResolution = ConfiguredBindingRecordResolution & {
conversation: ConfiguredBindingConversation;
compiledBinding: CompiledConfiguredBinding;

View File

@@ -2,11 +2,6 @@ import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registr
import type { PluginDiscoveryResult } from "../../plugins/discovery.js";
import { resolveBundledChannelRootScope } from "./bundled-root.js";
/**
* Lists bundled plugin package ids from the catalog for a root-compatible
* caller. The package root argument is retained for older call sites; discovery
* state now owns the actual catalog root.
*/
export function listBundledChannelPluginIdsForRoot(
_packageRoot: string,
env: NodeJS.ProcessEnv = process.env,
@@ -21,10 +16,6 @@ export function listBundledChannelPluginIdsForRoot(
.toSorted((left, right) => left.localeCompare(right));
}
/**
* Lists bundled channel ids from catalog metadata for a root-compatible caller.
* This can differ from plugin ids when one plugin manifest exposes aliases.
*/
export function listBundledChannelIdsForRoot(
_packageRoot: string,
env: NodeJS.ProcessEnv = process.env,
@@ -40,7 +31,6 @@ export function listBundledChannelIdsForRoot(
.toSorted((left, right) => left.localeCompare(right));
}
/** Lists bundled plugin package ids for the active bundled root scope. */
export function listBundledChannelPluginIds(
env: NodeJS.ProcessEnv = process.env,
discovery?: PluginDiscoveryResult,
@@ -52,7 +42,6 @@ export function listBundledChannelPluginIds(
);
}
/** Lists bundled channel ids for the active bundled root scope. */
export function listBundledChannelIds(
env: NodeJS.ProcessEnv = process.env,
discovery?: PluginDiscoveryResult,

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