Compare commits

..

1 Commits

Author SHA1 Message Date
Vincent Koc
a0a9501990 fix(plugins): restore release package compatibility 2026-06-17 15:04:16 +08:00
48 changed files with 263 additions and 351 deletions

View File

@@ -26,7 +26,6 @@ Docs: https://docs.openclaw.ai
- Channels and delivery: preserve account-scoped DM channel send policy, intentional rich-message line breaks in Telegram and status output, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Feishu dynamic-agent routes after persisted binding reuse, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #93164, #92679, #89421, #89943, #42837, #92814, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @liuhao1024, @lundog, @TurboTheTurtle, and @yhterrance.
- Gemini CLI: use the selected OpenClaw OAuth/API-key auth profile in an isolated Gemini CLI runtime home, preventing ambient Google machine credentials from overriding the chosen profile. (#88748) Thanks @jason-allen-oneal and @shakkernerd.
- Feishu: fetch quoted/replied message content before the empty-message guard so a mention-only reply that quotes a message with meaningful content is no longer dropped. (#90192) Thanks @bladin.
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, clamp trusted subagent thinking overrides through provider/model fallback, preserve yielded media completions, deliver channel message-tool final replies through auto-reply while hiding internal delivery hints, restore reset archive fallback reads when active async transcripts are missing, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions, slash-command block replies, and trajectory export commands in WebChat, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92412, #92146, #92879, #91287, #92468, #92510, #91246, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @masatohoshino, @CadanHu, @ZengWen-DT, @zhangguiping-xydt, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, recover invalid OpenAI reasoning signatures and genericized Anthropic thinking-signature replay errors, route OAuth image defaults through Codex for eligible OpenAI profiles, avoid eager tool streaming for Claude 4.5 in Copilot, quarantine unreadable and post-hook OpenAI/Anthropic-family tool schemas without broadening allowed tool choices, deliver explicit thinking-off requests to LM Studio binary-thinking models, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #92941, #92201, #92916, #92824, #75393, #92908, #92921, #92928, #92002, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @mmyzwl, @CarlCapital, @bek91, @Kailigithub, @vincentkoc, @rohitjavvadi, @samson910022, @nxmxbbd, @liuhao1024, @bymle, and @mushuiyu886.

View File

@@ -424,7 +424,6 @@ async function dispatchMessage(params: {
currentCfg?: ClawdbotConfig;
event: FeishuMessageEvent;
channelRuntime?: PluginRuntime["channel"];
botOpenId?: string;
}) {
const runtime = createRuntimeEnv();
const feishuConfig = params.cfg.channels?.feishu;
@@ -445,7 +444,6 @@ async function dispatchMessage(params: {
await handleFeishuMessage({
cfg,
event: params.event,
botOpenId: params.botOpenId,
runtime,
channelRuntime: params.channelRuntime,
});
@@ -4166,150 +4164,6 @@ describe("handleFeishuMessage command authorization", () => {
// No reply should be dispatched: empty message is silently skipped
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("does not drop empty-text message when it quotes a parent message (#90177)", async () => {
// A Feishu reply containing only @bot (no additional text) was being
// dropped before the quoted message content was fetched. The handler
// should fetch quoted content first and only skip if all of current
// text, media, and quoted content are empty.
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValueOnce({
messageId: "om_quoted_001",
chatId: "oc-dm",
content: "quoted message content from parent",
contentType: "text",
});
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-reply-only-bot",
},
},
message: {
message_id: "msg-empty-with-quote",
parent_id: "om_quoted_001",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
// Empty text — only @bot mention, no additional content
content: JSON.stringify({ text: "" }),
},
};
await dispatchMessage({ cfg, event });
// A reply should be dispatched because quoted content provides context
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("dispatches mention-only group reply with quoted content in requireMention:true group (#90177)", async () => {
// #90177 is specifically about group chats. The empty-message drop happens
// after the group admission/mention gate, so the fix must also work when
// the sender mentions the bot in a requireMention:true group and quotes a
// parent message with meaningful content — the reply should dispatch with
// the quoted text in the body.
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValueOnce({
messageId: "om_group_quoted_001",
chatId: "oc-group-90177",
content: "parent message with context",
contentType: "text",
});
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "open",
groups: {
"oc-group-90177": {
requireMention: true,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-group-sender",
},
},
message: {
message_id: "msg-group-empty-with-quote",
parent_id: "om_group_quoted_001",
chat_id: "oc-group-90177",
chat_type: "group",
message_type: "text",
// Empty text — only @bot mention, no additional content
content: JSON.stringify({ text: "" }),
// Bot mention so the message passes the requireMention gate
mentions: [
{ key: "@_bot_1", id: { open_id: "ou-bot-90177" }, name: "Bot", tenant_key: "" },
],
},
};
await dispatchMessage({ cfg, event, botOpenId: "ou-bot-90177" });
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const context = mockCallArg<{ Body?: string }>(mockFinalizeInboundContext, 0, 0);
expect(context.Body).toContain("[Replying to:");
expect(context.Body).toContain("parent message with context");
});
it("does not over-fetch quoted message for unmentioned empty reply in requireMention:true group (#90177)", async () => {
// An empty-text reply that quotes a parent but does NOT mention the bot
// in a requireMention:true group should be rejected at the mention gate
// before the quoted message is fetched, so getMessageFeishu is never
// called and nothing is dispatched.
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "open",
groups: {
"oc-group-90177-neg": {
requireMention: true,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-group-sender-neg",
},
},
message: {
message_id: "msg-group-unmentioned-empty-quote",
parent_id: "om_group_quoted_neg",
chat_id: "oc-group-90177-neg",
chat_type: "group",
message_type: "text",
// Empty text with no bot mention
content: JSON.stringify({ text: "" }),
},
};
await dispatchMessage({ cfg, event, botOpenId: "ou-bot-90177-neg" });
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
});
describe("createFeishuMessageReceiveHandler media dedupe", () => {

View File

@@ -1026,57 +1026,15 @@ export async function handleFeishuMessage(params: {
log,
accountId: account.accountId,
});
// Fetch quoted/replied message content before the empty-message guard
// so a reply with only @bot (no text, no media) is not dropped when
// the quoted message carries meaningful content.
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
quotedMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (
quotedMessageInfo &&
(await shouldIncludeFetchedGroupContextMessage({
cfg,
accountId: account.accountId,
chatId: ctx.chatId,
isGroup,
allowFrom: effectiveGroupSenderAllowFrom,
mode: contextVisibilityMode,
kind: "quote",
senderId: quotedMessageInfo.senderId,
senderType: quotedMessageInfo.senderType,
}))
) {
quotedContent = quotedMessageInfo.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
} else if (quotedMessageInfo) {
log(
`feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
);
}
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
}
}
// Skip messages with no text content, no media attachments, and no quoted
// content. Feishu can deliver empty-text events (e.g. `{"text":""}`) when
// a user sends a blank message or when media parsing produces an empty
// string. Writing a blank user turn to the session causes downstream LLM
// providers (e.g. MiniMax) to reject the request with "messages must not
// be empty" errors. Logging the skip avoids silent loss without polluting
// the agent session. Quoted content is checked too so a reply-only @bot
// with quoted context is not dropped.
if (!ctx.content.trim() && mediaList.length === 0 && !quotedContent?.trim()) {
// Skip messages with no text content and no media attachments. Feishu can
// deliver empty-text events (e.g. `{"text":""}`) when a user sends a blank
// message or when media parsing produces an empty string. Writing a blank
// user turn to the session causes downstream LLM providers (e.g. MiniMax)
// to reject the request with "messages must not be empty" errors. Logging
// the skip avoids silent loss without polluting the agent session.
if (!ctx.content.trim() && mediaList.length === 0) {
log(
`feishu[${account.accountId}]: skipping empty message (no text, no media, no quoted) from ${ctx.senderOpenId}`,
`feishu[${account.accountId}]: skipping empty message (no text, no media) from ${ctx.senderOpenId}`,
);
return;
}
@@ -1149,6 +1107,44 @@ export async function handleFeishuMessage(params: {
).commandAccess.authorized
: undefined;
// Fetch quoted/replied message content if parentId exists
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
quotedMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (
quotedMessageInfo &&
(await shouldIncludeFetchedGroupContextMessage({
cfg,
accountId: account.accountId,
chatId: ctx.chatId,
isGroup,
allowFrom: effectiveGroupSenderAllowFrom,
mode: contextVisibilityMode,
kind: "quote",
senderId: quotedMessageInfo.senderId,
senderType: quotedMessageInfo.senderType,
}))
) {
quotedContent = quotedMessageInfo.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
} else if (quotedMessageInfo) {
log(
`feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
);
}
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
}
}
const isTopicSessionForThread =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||

View File

@@ -0,0 +1,30 @@
// Feishu client module import behavior tests.
import { afterEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
vi.doUnmock("@larksuiteoapi/node-sdk");
vi.doUnmock("@openclaw/proxyline");
vi.resetModules();
});
describe("Feishu client module", () => {
it("loads when the SDK has no default HTTP instance", async () => {
vi.doMock("@larksuiteoapi/node-sdk", () => ({
AppType: { SelfBuild: "self" },
Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
LoggerLevel: { info: "info" },
Client: vi.fn(),
WSClient: vi.fn(),
EventDispatcher: vi.fn(),
defaultHttpInstance: undefined,
}));
vi.doMock("@openclaw/proxyline", () => ({
createAmbientNodeProxyAgent: vi.fn(),
hasAmbientNodeProxyConfigured: vi.fn(() => false),
}));
await expect(import("./client.js")).resolves.toMatchObject({
createFeishuClient: expect.any(Function),
});
});
});

View File

@@ -387,6 +387,23 @@ describe("createFeishuClient HTTP timeout", () => {
});
});
it("rejects client creation when the SDK default HTTP instance is unavailable", () => {
setFeishuClientRuntimeForTest({
sdk: {
defaultHttpInstance: undefined as never,
},
});
expect(() =>
createFeishuClient({
appId: "app-default-http",
appSecret: "secret-default-http", // pragma: allowlist secret
accountId: "default-http-instance",
}),
).toThrow("Feishu SDK default HTTP instance is unavailable");
expect(clientCtorMock).not.toHaveBeenCalled();
});
it("evicts client cache when SDK is replaced via setFeishuClientRuntimeForTest (#83911)", () => {
const ctorCountA = clientCtorMock.mock.calls.length;

View File

@@ -67,12 +67,14 @@ let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk;
// If a future SDK version adds more interceptors, the upgrade will need
// compatibility verification regardless.
{
const inst = Lark.defaultHttpInstance as {
interceptors?: {
request: { handlers: unknown[]; use: (fn: (req: unknown) => unknown) => void };
};
};
if (inst.interceptors?.request) {
const inst = Lark.defaultHttpInstance as
| {
interceptors?: {
request: { handlers: unknown[]; use: (fn: (req: unknown) => unknown) => void };
};
}
| undefined;
if (inst?.interceptors?.request) {
inst.interceptors.request.handlers = [];
inst.interceptors.request.use((req: unknown) => {
const r = req as { headers?: Record<string, string> };
@@ -119,9 +121,10 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
* but injects a default request timeout and User-Agent header to prevent
* indefinite hangs and set a standardized User-Agent per OAPI best practices.
*/
function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
const base: FeishuHttpInstanceLike = feishuClientSdk.defaultHttpInstance;
function createTimeoutHttpInstance(
base: FeishuHttpInstanceLike,
defaultTimeoutMs: number,
): Lark.HttpInstance {
function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions<D>;
}
@@ -175,13 +178,19 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client
return cached.client;
}
// Create new client with timeout-aware HTTP instance
const defaultHttpInstance = feishuClientSdk.defaultHttpInstance as
| FeishuHttpInstanceLike
| undefined;
if (!defaultHttpInstance) {
throw new Error("Feishu SDK default HTTP instance is unavailable");
}
const client = new feishuClientSdk.Client({
appId,
appSecret,
appType: feishuClientSdk.AppType.SelfBuild,
domain: resolveDomain(domain),
httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs),
httpInstance: createTimeoutHttpInstance(defaultHttpInstance, defaultHttpTimeoutMs),
});
// Cache it

View File

@@ -8,7 +8,7 @@
"name": "@openclaw/matrix",
"version": "2026.6.8",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "0.6.0",
"@matrix-org/matrix-sdk-crypto-nodejs": "0.4.0",
"@matrix-org/matrix-sdk-crypto-wasm": "18.3.0",
"fake-indexeddb": "6.2.5",
"markdown-it": "14.2.0",
@@ -46,9 +46,9 @@
}
},
"node_modules/@matrix-org/matrix-sdk-crypto-nodejs": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.6.0.tgz",
"integrity": "sha512-AndGryzkDtFbaDyPBAQ2B4pUhaA/q4HJf3wgiGpPa/70DsdY1Z3R5Wn9yp+56CeHOpk61mNHz/8WDPlzrZDSJw==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.4.0.tgz",
"integrity": "sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -56,7 +56,7 @@
"node-downloader-helper": "^2.1.9"
},
"engines": {
"node": ">= 24"
"node": ">= 22"
}
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {

View File

@@ -8,7 +8,7 @@
},
"type": "module",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "0.6.0",
"@matrix-org/matrix-sdk-crypto-nodejs": "0.4.0",
"@matrix-org/matrix-sdk-crypto-wasm": "18.3.0",
"fake-indexeddb": "6.2.5",
"markdown-it": "14.2.0",

12
pnpm-lock.yaml generated
View File

@@ -957,8 +957,8 @@ importers:
extensions/matrix:
dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs':
specifier: 0.6.0
version: 0.6.0
specifier: 0.4.0
version: 0.4.0
'@matrix-org/matrix-sdk-crypto-wasm':
specifier: 18.3.0
version: 18.3.0
@@ -2926,9 +2926,9 @@ packages:
'@lydell/node-pty@1.2.0-beta.12':
resolution: {integrity: sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g==}
'@matrix-org/matrix-sdk-crypto-nodejs@0.6.0':
resolution: {integrity: sha512-AndGryzkDtFbaDyPBAQ2B4pUhaA/q4HJf3wgiGpPa/70DsdY1Z3R5Wn9yp+56CeHOpk61mNHz/8WDPlzrZDSJw==}
engines: {node: '>= 24'}
'@matrix-org/matrix-sdk-crypto-nodejs@0.4.0':
resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==}
engines: {node: '>= 22'}
'@matrix-org/matrix-sdk-crypto-wasm@18.3.0':
resolution: {integrity: sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==}
@@ -8873,7 +8873,7 @@ snapshots:
'@lydell/node-pty-win32-arm64': 1.2.0-beta.12
'@lydell/node-pty-win32-x64': 1.2.0-beta.12
'@matrix-org/matrix-sdk-crypto-nodejs@0.6.0':
'@matrix-org/matrix-sdk-crypto-nodejs@0.4.0':
dependencies:
https-proxy-agent: 7.0.6
node-downloader-helper: 2.1.11

View File

@@ -14,14 +14,6 @@ if common_git_dir=$(git -C "$script_parent_dir" rev-parse --path-format=absolute
fi
fi
# This wrapper parses GitHub CLI JSON with jq. Ignore interactive color settings
# inherited from operator shells so machine output stays valid JSON.
export NO_COLOR=1
export CLICOLOR=0
export CLICOLOR_FORCE=0
unset GH_FORCE_TTY
export GH_PAGER=cat
usage() {
cat <<USAGE
Usage:

View File

@@ -4,7 +4,7 @@ import type { Model } from "../../llm/types.js";
import { createSessionManagerRuntimeRegistry } from "./session-manager-runtime-registry.js";
/** Runtime knobs consumed by the compaction safeguard extension. */
type CompactionSafeguardRuntimeValue = {
export type CompactionSafeguardRuntimeValue = {
maxHistoryShare?: number;
contextWindowTokens?: number;
identifierPolicy?: AgentCompactionIdentifierPolicy;

View File

@@ -6,6 +6,8 @@
import { createSubsystemLogger } from "../../logging/subsystem.js";
export {
AUTH_PROFILE_FILENAME,
AUTH_STATE_FILENAME,
LEGACY_AUTH_FILENAME,
} from "./path-constants.js";
/** Current persisted auth profile store schema version. */

View File

@@ -208,4 +208,7 @@ export function syncPersistedExternalCliAuthProfiles(
return next ?? store;
}
// Compat aliases while file/function naming catches up.
export const overlayExternalOAuthProfiles = overlayExternalAuthProfiles;
export const shouldPersistExternalOAuthProfile = shouldPersistExternalAuthProfile;
export { testing as __testing };

View File

@@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderExternalAuthProfile } from "../../plugins/types.js";
import {
testing,
overlayExternalAuthProfiles,
shouldPersistExternalAuthProfile,
overlayExternalOAuthProfiles,
shouldPersistExternalOAuthProfile,
} from "./external-auth.js";
import { readManagedExternalCliCredential } from "./external-cli-sync.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
@@ -78,7 +78,7 @@ describe("auth external oauth helpers", () => {
},
]);
const store = overlayExternalAuthProfiles(createStore());
const store = overlayExternalOAuthProfiles(createStore());
const profile = requireProfile(store, "openai:default");
expect(profile.type).toBe("oauth");
@@ -94,7 +94,7 @@ describe("auth external oauth helpers", () => {
};
readCodexCliCredentialsCachedMock.mockReturnValueOnce(createCredential());
overlayExternalAuthProfiles(createStore(), {
overlayExternalOAuthProfiles(createStore(), {
allowKeychainPrompt: false,
config: cfg,
externalCliProviderIds: ["openai"],
@@ -128,7 +128,7 @@ describe("auth external oauth helpers", () => {
}),
});
const overlaid = overlayExternalAuthProfiles(store);
const overlaid = overlayExternalOAuthProfiles(store);
expect(readCodexCliCredentialsCachedMock).not.toHaveBeenCalled();
expect(overlaid.profiles["openai:work"]).toEqual(store.profiles["openai:work"]);
@@ -143,7 +143,7 @@ describe("auth external oauth helpers", () => {
},
]);
const shouldPersist = shouldPersistExternalAuthProfile({
const shouldPersist = shouldPersistExternalOAuthProfile({
store: createStore({ "openai:default": credential }),
profileId: "openai:default",
credential,
@@ -162,7 +162,7 @@ describe("auth external oauth helpers", () => {
},
]);
const shouldPersist = shouldPersistExternalAuthProfile({
const shouldPersist = shouldPersistExternalOAuthProfile({
store: createStore({ "openai:default": credential }),
profileId: "openai:default",
credential,
@@ -180,7 +180,7 @@ describe("auth external oauth helpers", () => {
},
]);
const shouldPersist = shouldPersistExternalAuthProfile({
const shouldPersist = shouldPersistExternalOAuthProfile({
store: createStore({ "openai:default": credential }),
profileId: "openai:default",
credential,
@@ -199,7 +199,7 @@ describe("auth external oauth helpers", () => {
}),
);
const overlaid = overlayExternalAuthProfiles(
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai:default": createCredential({
access: "stale-store-access-token",
@@ -231,7 +231,7 @@ describe("auth external oauth helpers", () => {
} as OAuthCredential;
readCodexCliCredentialsCachedMock.mockReturnValue(cliCredential);
const overlaid = overlayExternalAuthProfiles(
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai:default": tokenlessCredential,
}),
@@ -263,7 +263,7 @@ describe("auth external oauth helpers", () => {
}),
);
const overlaid = overlayExternalAuthProfiles(
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai:default": createCredential({
access: "healthy-local-access-token",
@@ -287,7 +287,7 @@ describe("auth external oauth helpers", () => {
}),
);
const overlaid = overlayExternalAuthProfiles(
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai:default": {
type: "api_key",
@@ -313,7 +313,7 @@ describe("auth external oauth helpers", () => {
}),
);
const overlaid = overlayExternalAuthProfiles(
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai:default": createCredential({
access: "expired-local-access-token",

View File

@@ -6,7 +6,7 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
/** Legacy OAuth ref source persisted by older credential stores. */
const LEGACY_OAUTH_REF_SOURCE = "openclaw-credentials";
export const LEGACY_OAUTH_REF_SOURCE = "openclaw-credentials";
/** Legacy OAuth ref provider persisted by older credential stores. */
export const LEGACY_OAUTH_REF_PROVIDER = "openai-codex";

View File

@@ -16,7 +16,7 @@ const EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_NONCE_MARKER = ":nonce:";
const EXEC_APPROVAL_FOLLOWUP_RUNTIME_HANDOFF_TTL_MS = 5 * 60 * 1000;
/** Single-use capability payload consumed by a follow-up agent turn. */
type ExecApprovalFollowupRuntimeHandoff = {
export type ExecApprovalFollowupRuntimeHandoff = {
kind: "exec-approval-followup";
approvalId: string;
sessionKey: string;
@@ -25,7 +25,7 @@ type ExecApprovalFollowupRuntimeHandoff = {
};
/** Registration handle returned to the gateway approval callback. */
type ExecApprovalFollowupRuntimeHandoffRegistration = {
export type ExecApprovalFollowupRuntimeHandoffRegistration = {
handoffId: string;
idempotencyKey: string;
};

View File

@@ -42,7 +42,7 @@ function loadExecApprovalCommandSpansRuntime(): Promise<ExecApprovalCommandSpans
}
/** Gateway payload fields used to register or wait for an exec approval decision. */
type RequestExecApprovalDecisionParams = {
export type RequestExecApprovalDecisionParams = {
id: string;
command?: string;
commandArgv?: string[];

View File

@@ -62,7 +62,7 @@ import type {
import type { AgentToolResult } from "./runtime/index.js";
/** Full input bundle for gateway-host allowlist and approval processing. */
type ProcessGatewayAllowlistParams = {
export type ProcessGatewayAllowlistParams = {
command: string;
workdir: string;
env: Record<string, string>;
@@ -104,7 +104,7 @@ type ProcessGatewayAllowlistParams = {
};
/** Gateway allowlist outcome before command execution continues. */
type ProcessGatewayAllowlistResult = {
export type ProcessGatewayAllowlistResult = {
execCommandOverride?: string;
allowWithoutEnforcedCommand?: boolean;
pendingResult?: AgentToolResult<ExecToolDetails>;

View File

@@ -37,6 +37,8 @@ import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import type { AgentToolResult } from "./runtime/index.js";
import { callGatewayTool } from "./tools/gateway.js";
export type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
const APPROVED_NODE_INVOKE_SCOPES = [WRITE_SCOPE, APPROVALS_SCOPE];
function resolveNodeAutoReviewReason(params: {

View File

@@ -28,13 +28,13 @@ import type {
} from "./agent-cache-store.js";
/** Options for an agent/scope-scoped SQLite runtime cache. */
type SqliteAgentCacheStoreOptions = OpenClawAgentDatabaseOptions & {
export type SqliteAgentCacheStoreOptions = OpenClawAgentDatabaseOptions & {
scope: string;
now?: () => number;
};
/** Options for writing a single SQLite agent cache entry. */
type WriteSqliteAgentCacheEntryOptions = SqliteAgentCacheStoreOptions &
export type WriteSqliteAgentCacheEntryOptions = SqliteAgentCacheStoreOptions &
AgentRuntimeCacheWriteOptions;
type CacheEntriesTable = OpenClawAgentKyselyDatabase["cache_entries"];
@@ -297,7 +297,7 @@ export function clearExpiredSqliteAgentCacheEntries(
}
/** Agent runtime cache store implementation backed by OpenClaw's SQLite DB. */
class SqliteAgentCacheStore implements AgentRuntimeCacheStore {
export class SqliteAgentCacheStore implements AgentRuntimeCacheStore {
readonly #options: SqliteAgentCacheStoreOptions;
constructor(options: SqliteAgentCacheStoreOptions) {
@@ -349,6 +349,6 @@ class SqliteAgentCacheStore implements AgentRuntimeCacheStore {
/** Create a SQLite-backed agent runtime cache store. */
export function createSqliteAgentCacheStore(
options: SqliteAgentCacheStoreOptions,
): AgentRuntimeCacheStore {
): SqliteAgentCacheStore {
return new SqliteAgentCacheStore(options);
}

View File

@@ -12,7 +12,7 @@ export type AgentAttemptLifecycleState = {
};
/** Event shape emitted by runtimes during an agent attempt. */
type AgentAttemptLifecycleEvent = {
export type AgentAttemptLifecycleEvent = {
stream: string;
data?: Record<string, unknown>;
sessionKey?: string;

View File

@@ -15,7 +15,7 @@ import {
import type { AgentCommandOpts } from "./types.js";
/** Parameters for merging and persisting a session entry update. */
type PersistSessionEntryParams = {
export type PersistSessionEntryParams = {
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath: string;

View File

@@ -66,10 +66,10 @@ function createRestartOnlyAbortSignal(source: AbortSignal | undefined): {
}
/** Per-payload durable delivery status. */
type AgentCommandDeliveryPayloadStatus = "sent" | "suppressed" | "failed";
export type AgentCommandDeliveryPayloadStatus = "sent" | "suppressed" | "failed";
/** Delivery outcome for one normalized outbound payload. */
type AgentCommandDeliveryPayloadOutcome = {
export type AgentCommandDeliveryPayloadOutcome = {
index: number;
status: AgentCommandDeliveryPayloadStatus;
reason?: string;
@@ -84,7 +84,7 @@ type AgentCommandDeliveryPayloadOutcome = {
};
/** Aggregate delivery status for an agent command result. */
type AgentCommandDeliveryStatus = {
export type AgentCommandDeliveryStatus = {
requested: true;
attempted: boolean;
status: "sent" | "suppressed" | "partial_failed" | "failed";
@@ -100,7 +100,7 @@ type AgentCommandDeliveryStatus = {
};
/** Agent command result after payload normalization and optional delivery. */
type AgentCommandDeliveryResult = {
export type AgentCommandDeliveryResult = {
payloads: ReturnType<typeof projectOutboundPayloadPlanForJson>;
meta: EmbeddedAgentRunMeta & AgentCommandResultMetaOverrides;
didSendViaMessagingTool?: boolean;

View File

@@ -41,7 +41,7 @@ import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js";
import { clearAllCliSessions } from "../cli-session.js";
/** Resolved command session identity plus backing store metadata. */
type SessionResolution = {
export type SessionResolution = {
sessionId: string;
sessionKey?: string;
sessionEntry?: SessionEntry;

View File

@@ -29,7 +29,7 @@ export type AgentCommandResultMetaOverrides = {
};
/** ACP turn source markers accepted by trusted command callsites. */
type AcpTurnSource = "manual_spawn";
export type AcpTurnSource = "manual_spawn";
/** Channel/account/thread context carried into an agent run. */
export type AgentRunContext = {

View File

@@ -12,7 +12,7 @@ import type { FailoverReason } from "../../embedded-agent-helpers.js";
import { log } from "../logger.js";
/** Structured fields emitted whenever embedded run failover chooses an action. */
type FailoverDecisionLoggerInput = {
export type FailoverDecisionLoggerInput = {
stage: "prompt" | "assistant";
decision: "rotate_profile" | "fallback_model" | "surface_error";
runId?: string;
@@ -31,7 +31,7 @@ type FailoverDecisionLoggerInput = {
};
/** Stable context captured before a concrete failover decision is known. */
type FailoverDecisionLoggerBase = Omit<FailoverDecisionLoggerInput, "decision" | "status">;
export type FailoverDecisionLoggerBase = Omit<FailoverDecisionLoggerInput, "decision" | "status">;
/**
* Derives timeout failure reasons for logs that were built from timeout state

View File

@@ -84,7 +84,7 @@ export type TraceAttempt = {
status?: number;
};
type ExecutionTrace = {
export type ExecutionTrace = {
winnerProvider?: string;
winnerModel?: string;
attempts?: TraceAttempt[];
@@ -92,7 +92,7 @@ type ExecutionTrace = {
runner?: "embedded" | "cli";
};
type RequestShapingTrace = {
export type RequestShapingTrace = {
authMode?: string;
thinking?: string;
reasoning?: string;
@@ -102,7 +102,7 @@ type RequestShapingTrace = {
blockStreaming?: string;
};
type PromptSegmentTrace = {
export type PromptSegmentTrace = {
key: string;
chars: number;
};
@@ -114,13 +114,13 @@ export type ToolSummaryTrace = {
totalToolTimeMs?: number;
};
type CompletionTrace = {
export type CompletionTrace = {
finishReason?: string;
stopReason?: string;
refusal?: boolean;
};
type ContextManagementTrace = {
export type ContextManagementTrace = {
sessionCompactions?: number;
lastTurnCompactions?: number;
preflightCompactionApplied?: boolean;

View File

@@ -145,7 +145,7 @@ export async function awaitAgentHarnessAgentEndHook(params: {
}
/** Normalized before-finalize hook decision consumed by harness loops. */
type AgentHarnessBeforeAgentFinalizeOutcome =
export type AgentHarnessBeforeAgentFinalizeOutcome =
| { action: "continue" }
| { action: "revise"; reason: string }
| { action: "finalize"; reason?: string };

View File

@@ -18,7 +18,7 @@ import { buildAgentHookContext, type AgentHarnessHookContext } from "./hook-cont
const log = createSubsystemLogger("agents/harness");
/** Prompt/developer-instruction pair after harness prompt-build hooks run. */
type AgentHarnessPromptBuildResult = {
export type AgentHarnessPromptBuildResult = {
prompt: string;
developerInstructions: string;
};

View File

@@ -83,7 +83,7 @@ export type AgentHarnessDeliveryDefaults = {
sourceVisibleReplies?: "automatic" | "message_tool";
};
type AgentHarnessRunCapability = {
export type AgentHarnessRunCapability = {
id: string;
label: string;
pluginId?: string;
@@ -98,22 +98,22 @@ type AgentHarnessRunCapability = {
runAttempt(params: AgentHarnessAttemptParams): Promise<AgentHarnessAttemptResult>;
};
type AgentHarnessSideQuestionCapability = {
export type AgentHarnessSideQuestionCapability = {
runSideQuestion?(params: AgentHarnessSideQuestionParams): Promise<AgentHarnessSideQuestionResult>;
};
type AgentHarnessClassificationCapability = {
export type AgentHarnessClassificationCapability = {
classify?(
result: AgentHarnessAttemptResult,
ctx: AgentHarnessAttemptParams,
): AgentHarnessResultClassification | undefined;
};
type AgentHarnessCompactionCapability = {
export type AgentHarnessCompactionCapability = {
compact?(params: AgentHarnessCompactParams): Promise<AgentHarnessCompactResult | undefined>;
};
type AgentHarnessSessionLifecycleCapability = {
export type AgentHarnessSessionLifecycleCapability = {
reset?(params: AgentHarnessResetParams): Promise<void> | void;
dispose?(): Promise<void> | void;
};

View File

@@ -15,7 +15,7 @@ import {
import { isAnthropicBillingError, isApiKeyRateLimitError } from "./live-auth-keys.js";
import { isModelNotFoundErrorMessage } from "./live-model-errors.js";
type LiveProviderDriftReason =
export type LiveProviderDriftReason =
| "auth"
| "billing"
| "model-not-found"
@@ -24,13 +24,13 @@ type LiveProviderDriftReason =
| "timeout";
/** A normalized reason for skipping or soft-failing live provider drift. */
type LiveProviderDriftDecision = {
export type LiveProviderDriftDecision = {
label: string;
reason: LiveProviderDriftReason;
};
/** Classifier options that control which live-provider drift reasons are allowed. */
type LiveProviderDriftOptions = {
export type LiveProviderDriftOptions = {
allowAuth?: boolean;
allowBilling?: boolean;
allowModelNotFound?: boolean;
@@ -41,7 +41,7 @@ type LiveProviderDriftOptions = {
};
/** Converts arbitrary thrown values into text for provider drift matchers. */
function liveProviderErrorText(error: unknown): string {
export function liveProviderErrorText(error: unknown): string {
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
@@ -69,12 +69,12 @@ export function isLiveRateLimitDrift(error: unknown): boolean {
}
/** Returns whether an error is expected live timeout drift. */
function isLiveTimeoutDrift(error: unknown): boolean {
export function isLiveTimeoutDrift(error: unknown): boolean {
return isTimeoutErrorMessage(liveProviderErrorText(error));
}
/** Returns whether an error is expected live missing-model drift. */
function isLiveModelNotFoundDrift(error: unknown): boolean {
export function isLiveModelNotFoundDrift(error: unknown): boolean {
return isModelNotFoundErrorMessage(liveProviderErrorText(error));
}

View File

@@ -141,6 +141,19 @@ function findPersistedTaskForRecentMediaGenerationStart(params: {
});
}
/** Returns whether a task is an active session-scoped media generation task. */
export function isActiveMediaGenerationTask(params: {
task: TaskRecord;
taskKind: string;
}): boolean {
return (
params.task.runtime === "cli" &&
params.task.scopeKind === "session" &&
params.task.taskKind === params.taskKind &&
(params.task.status === "queued" || params.task.status === "running")
);
}
/** Records a just-started media task so duplicate guards work before persistence. */
export function recordRecentMediaGenerationTaskStartForSession(params: {
sessionKey?: string;
@@ -281,7 +294,7 @@ export function resetRecentMediaGenerationDuplicateGuardsForTests() {
}
/** Extracts a provider id from a media task source id with the given prefix. */
function getMediaGenerationTaskProviderId(
export function getMediaGenerationTaskProviderId(
task: TaskRecord,
sourcePrefix: string,
): string | undefined {

View File

@@ -41,7 +41,7 @@ export type ResolveImplicitProvidersForModelsJson = (params: {
}) => Promise<Record<string, ProviderConfig>>;
/** Planned models.json write/noop/skip result plus plugin catalog sidecar writes. */
type ModelsJsonPlan =
export type ModelsJsonPlan =
| {
action: "skip";
pluginCatalogWrites?: Record<string, string>;

View File

@@ -19,7 +19,7 @@ type ProviderRuntimeModule = typeof import("../plugins/provider-runtime.js");
let NON_ENV_SECRETREF_MARKER: typeof import("./model-auth-markers.js").NON_ENV_SECRETREF_MARKER;
let MINIMAX_OAUTH_MARKER: typeof import("./model-auth-markers.js").MINIMAX_OAUTH_MARKER;
let CUSTOM_LOCAL_AUTH_MARKER: typeof import("./model-auth-markers.js").CUSTOM_LOCAL_AUTH_MARKER;
let resolveApiKeyFromCredential: typeof import("./models-config.providers.secret-helpers.js").resolveApiKeyFromCredential;
let resolveApiKeyFromCredential: typeof import("./models-config.providers.secrets.js").resolveApiKeyFromCredential;
let createProviderApiKeyResolver: typeof import("./models-config.providers.secrets.js").createProviderApiKeyResolver;
let createProviderAuthResolver: typeof import("./models-config.providers.secrets.js").createProviderAuthResolver;
let mockedResolveProviderSyntheticAuthWithPlugin: ReturnType<
@@ -29,10 +29,9 @@ let mockedResolveProviderSyntheticAuthWithPlugin: ReturnType<
async function loadProviderAuthModules() {
vi.doUnmock("../plugins/manifest-registry.js");
vi.doUnmock("../secrets/provider-env-vars.js");
const [providerRuntimeModule, markersModule, helperModule, secretsModule] = await Promise.all([
const [providerRuntimeModule, markersModule, secretsModule] = await Promise.all([
import("../plugins/provider-runtime.js"),
import("./model-auth-markers.js"),
import("./models-config.providers.secret-helpers.js"),
import("./models-config.providers.secrets.js"),
]);
mockedResolveProviderSyntheticAuthWithPlugin = vi.mocked(
@@ -41,7 +40,7 @@ async function loadProviderAuthModules() {
CUSTOM_LOCAL_AUTH_MARKER = markersModule.CUSTOM_LOCAL_AUTH_MARKER;
NON_ENV_SECRETREF_MARKER = markersModule.NON_ENV_SECRETREF_MARKER;
MINIMAX_OAUTH_MARKER = markersModule.MINIMAX_OAUTH_MARKER;
resolveApiKeyFromCredential = helperModule.resolveApiKeyFromCredential;
resolveApiKeyFromCredential = secretsModule.resolveApiKeyFromCredential;
createProviderApiKeyResolver = secretsModule.createProviderApiKeyResolver;
createProviderAuthResolver = secretsModule.createProviderAuthResolver;
}

View File

@@ -35,7 +35,7 @@ export type SecretDefaults = {
};
/** Resolved API key value plus provenance for discovery and secret-marker handling. */
type ProfileApiKeyResolution = {
export type ProfileApiKeyResolution = {
apiKey: string;
source: "plaintext" | "env-ref" | "non-env-ref";
discoveryApiKey?: string;

View File

@@ -28,6 +28,7 @@ import {
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
export type {
ProfileApiKeyResolution,
ProviderApiKeyResolver,
ProviderAuthResolver,
ProviderConfig,
@@ -35,8 +36,17 @@ export type {
} from "./models-config.providers.secret-helpers.js";
export {
listAuthProfilesForProvider,
normalizeApiKeyConfig,
normalizeConfiguredProviderApiKey,
normalizeHeaderValues,
normalizeResolvedEnvApiKey,
resolveApiKeyFromCredential,
resolveApiKeyFromProfiles,
resolveAwsSdkApiKeyVarName,
resolveEnvApiKeyVarName,
resolveMissingProviderApiKey,
toDiscoveryApiKey,
} from "./models-config.providers.secret-helpers.js";
type AuthProfileStoreInput = AuthProfileStore | (() => AuthProfileStore);

View File

@@ -17,7 +17,7 @@ import {
type BoundaryAllowedType = "file" | "directory";
/** Caller-provided path safety requirements for one fs bridge operation. */
type PathSafetyOptions = {
export type PathSafetyOptions = {
action: string;
aliasPolicy?: PathAliasPolicy;
requireWritable?: boolean;

View File

@@ -24,7 +24,7 @@ function stripWindowsNamespacePrefix(input: string): string {
return input;
}
function isWindowsDriveAbsolutePath(raw: string): boolean {
export function isWindowsDriveAbsolutePath(raw: string): boolean {
return /^[A-Za-z]:[\\/]/.test(stripWindowsNamespacePrefix(raw.trim()));
}

View File

@@ -22,7 +22,7 @@ type NoVncObserverTokenEntry = {
expiresAt: number;
};
type NoVncObserverTokenPayload = {
export type NoVncObserverTokenPayload = {
noVncPort: number;
password?: string;
};

View File

@@ -41,6 +41,10 @@ export function resolveMaterializedSandboxSkillsWorkspaceDir(rootDir: string): s
return path.join(rootDir, ...MATERIALIZED_SANDBOX_SKILLS_WORKSPACE_PARTS);
}
export function resolveMaterializedSandboxSkillsRoot(rootDir: string): string {
return path.join(resolveMaterializedSandboxSkillsWorkspaceDir(rootDir), "skills");
}
/** Returns true when a skill mount source exists inside the canonical mount root. */
export function isExistingWorkspaceSkillMountSource(params: {
rootDir: string;

View File

@@ -62,7 +62,7 @@ export function shouldDetachMediaGenerationTask(sessionKey: string | undefined):
}
/** Successful media generation output used to complete and wake detached tasks. */
type MediaGenerationExecutionResult = {
export type MediaGenerationExecutionResult = {
provider: string;
model: string;
count: number;

View File

@@ -56,6 +56,15 @@ type ProgressExpectation = {
progressSummary: string;
};
type DirectSendExpectation = {
sendMessageMock: unknown;
channel: string;
to: string;
threadId: string;
content: string;
mediaUrls: string[];
};
type FallbackAnnouncementExpectation = {
deliverAnnouncementMock: unknown;
requesterSessionKey: string;
@@ -171,6 +180,22 @@ export function expectRecordedTaskProgress({
expect(params.progressSummary).toBe(progressSummary);
}
export function expectDirectMediaSend({
sendMessageMock,
channel,
to,
threadId,
content,
mediaUrls,
}: DirectSendExpectation): void {
const params = requireMockFirstParam(sendMessageMock, "sendMessage params");
expect(params.channel).toBe(channel);
expect(params.to).toBe(to);
expect(params.threadId).toBe(threadId);
expect(params.content).toBe(content);
expect(params.mediaUrls).toEqual(mediaUrls);
}
export function expectFallbackMediaAnnouncement({
deliverAnnouncementMock,
requesterSessionKey,

View File

@@ -59,7 +59,7 @@ export const POLICY_REDIRECT_INVOKE_COMMANDS: ReadonlySet<string> = new Set([
"file.write",
]);
type NodeMediaAction =
export type NodeMediaAction =
| "camera_snap"
| "photos_latest"
| "camera_clip"

View File

@@ -82,6 +82,12 @@ export const PdfToolSchema = Type.Object({
maxBytesMb: optionalFiniteNumberSchema({ exclusiveMinimum: 0 }),
});
// ---------------------------------------------------------------------------
// Model resolution (mirrors image tool pattern)
// ---------------------------------------------------------------------------
export { resolvePdfModelConfigForTool } from "./pdf-tool.model-config.js";
function hasExplicitPdfToolModelConfig(config?: OpenClawConfig): boolean {
return (
hasToolModelConfig(coercePdfModelConfig(config)) ||

View File

@@ -11,8 +11,10 @@ import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-r
export {
createAgentToAgentPolicy,
createSessionVisibilityChecker,
createSessionVisibilityGuard,
createSessionVisibilityRowChecker,
listSpawnedSessionKeys,
resolveEffectiveSessionToolsVisibility,
} from "../../plugin-sdk/session-visibility.js";

View File

@@ -30,10 +30,10 @@ import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
/** Coarse session category used by session list/status tools. */
type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
/** Delivery target metadata attached to session rows. */
type SessionListDeliveryContext = {
export type SessionListDeliveryContext = {
channel?: string;
to?: string;
accountId?: string;

View File

@@ -88,6 +88,8 @@ export function resolveCurrentSessionClientAlias(params: {
return requesterKey;
}
export { listSpawnedSessionKeys };
export async function isRequesterSpawnedSessionVisible(params: {
requesterSessionKey: string;
targetSessionKey: string;
@@ -188,7 +190,7 @@ export function shouldResolveSessionIdInput(value: string): boolean {
return looksLikeSessionId(value) || !looksLikeSessionKey(value);
}
type SessionReferenceResolution =
export type SessionReferenceResolution =
| {
ok: true;
key: string;
@@ -197,7 +199,7 @@ type SessionReferenceResolution =
}
| { ok: false; status: "error" | "forbidden"; error: string };
type VisibleSessionReferenceResolution =
export type VisibleSessionReferenceResolution =
| {
ok: true;
key: string;
@@ -506,6 +508,8 @@ export async function resolveVisibleSessionReference(params: {
return { ok: true, key: resolvedKey, displayKey };
}
export const normalizeOptionalKey: (value?: string) => string | undefined = normalizeOptionalString;
export const testing = {
setDepsForTest(overrides?: Partial<{ callGateway: GatewayCaller }>) {
sessionsResolutionDeps = overrides

View File

@@ -1,55 +0,0 @@
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
const PR_SCRIPT = path.resolve("scripts/pr");
describe("scripts/pr machine output", () => {
it("disables inherited terminal color before calling gh", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-pr-machine-output-"));
const binDir = path.join(root, "bin");
const tracePath = path.join(root, "gh-env.txt");
try {
mkdirSync(binDir);
const gitPath = path.join(binDir, "git");
const ghPath = path.join(binDir, "gh");
writeFileSync(gitPath, "#!/usr/bin/env bash\nexit 1\n", "utf8");
writeFileSync(
ghPath,
[
"#!/usr/bin/env bash",
'printf "NO_COLOR=%s CLICOLOR=%s CLICOLOR_FORCE=%s GH_FORCE_TTY=%s GH_PAGER=%s\\n" "$NO_COLOR" "$CLICOLOR" "$CLICOLOR_FORCE" "${GH_FORCE_TTY-<unset>}" "$GH_PAGER" > "$TRACE_PATH"',
"exit 1",
"",
].join("\n"),
"utf8",
);
chmodSync(gitPath, 0o755);
chmodSync(ghPath, 0o755);
const result = spawnSync("bash", [PR_SCRIPT, "review-init", "1"], {
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
CLICOLOR: "1",
CLICOLOR_FORCE: "1",
GH_FORCE_TTY: "1",
GH_PAGER: "less",
NO_COLOR: "0",
PATH: `${binDir}:${process.env.PATH}`,
TRACE_PATH: tracePath,
},
});
expect(result.status).toBe(1);
expect(readFileSync(tracePath, "utf8")).toBe(
"NO_COLOR=1 CLICOLOR=0 CLICOLOR_FORCE=0 GH_FORCE_TTY=<unset> GH_PAGER=cat\n",
);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
});