fix: resolve ci failures

This commit is contained in:
Peter Steinberger
2026-01-18 08:44:43 +00:00
parent d776cfb4e1
commit 65bed815a8
24 changed files with 82 additions and 123 deletions

View File

@@ -11,9 +11,7 @@ describe("acp event mapper", () => {
{ type: "image", data: "abc", mimeType: "image/png" },
]);
expect(text).toBe(
"Hello\nFile contents\n[Resource link (Spec)] https://example.com",
);
expect(text).toBe("Hello\nFile contents\n[Resource link (Spec)] https://example.com");
});
it("extracts image blocks into gateway attachments", () => {

View File

@@ -57,7 +57,7 @@ export function formatToolTitle(
return `${base}: ${parts.join(", ")}`;
}
export function inferToolKind(name?: string): ToolKind | undefined {
export function inferToolKind(name?: string): ToolKind {
if (!name) return "other";
const normalized = name.toLowerCase();
if (normalized.includes("read")) return "read";

View File

@@ -9,10 +9,7 @@ import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
import { isMainModule } from "../infra/is-main.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { AcpGatewayAgent } from "./translator.js";
import type { AcpServerOptions } from "./types.js";
@@ -59,10 +56,10 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
});
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
const output = Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>;
const stream = ndJsonStream(input, output);
new AgentSideConnection((conn) => {
new AgentSideConnection((conn: AgentSideConnection) => {
agent = new AcpGatewayAgent(conn, gateway, opts);
agent.start();
return agent;

View File

@@ -35,10 +35,9 @@ export async function resolveSessionKey(params: {
params.meta.requireExisting ?? params.opts.requireExistingSession ?? false;
if (params.meta.sessionLabel) {
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ label: params.meta.sessionLabel },
);
const resolved = await params.gateway.request<{ ok: true; key: string }>("sessions.resolve", {
label: params.meta.sessionLabel,
});
if (!resolved?.key) {
throw new Error(`Unable to resolve session label: ${params.meta.sessionLabel}`);
}
@@ -47,10 +46,9 @@ export async function resolveSessionKey(params: {
if (params.meta.sessionKey) {
if (!requireExisting) return params.meta.sessionKey;
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ key: params.meta.sessionKey },
);
const resolved = await params.gateway.request<{ ok: true; key: string }>("sessions.resolve", {
key: params.meta.sessionKey,
});
if (!resolved?.key) {
throw new Error(`Session key not found: ${params.meta.sessionKey}`);
}
@@ -58,10 +56,9 @@ export async function resolveSessionKey(params: {
}
if (requestedLabel) {
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ label: requestedLabel },
);
const resolved = await params.gateway.request<{ ok: true; key: string }>("sessions.resolve", {
label: requestedLabel,
});
if (!resolved?.key) {
throw new Error(`Unable to resolve session label: ${requestedLabel}`);
}
@@ -70,10 +67,9 @@ export async function resolveSessionKey(params: {
if (requestedKey) {
if (!requireExisting) return requestedKey;
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ key: requestedKey },
);
const resolved = await params.gateway.request<{ ok: true; key: string }>("sessions.resolve", {
key: requestedKey,
});
if (!resolved?.key) {
throw new Error(`Session key not found: ${requestedKey}`);
}

View File

@@ -3,11 +3,7 @@ import { randomUUID } from "node:crypto";
import type { AcpSession } from "./types.js";
export type AcpSessionStore = {
createSession: (params: {
sessionKey: string;
cwd: string;
sessionId?: string;
}) => AcpSession;
createSession: (params: { sessionKey: string; cwd: string; sessionId?: string }) => AcpSession;
getSession: (sessionId: string) => AcpSession | undefined;
getSessionByRunId: (runId: string) => AcpSession | undefined;
setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void;
@@ -41,11 +37,7 @@ export function createInMemorySessionStore(): AcpSessionStore {
return sessionId ? sessions.get(sessionId) : undefined;
};
const setActiveRun: AcpSessionStore["setActiveRun"] = (
sessionId,
runId,
abortController,
) => {
const setActiveRun: AcpSessionStore["setActiveRun"] = (sessionId, runId, abortController) => {
const session = sessions.get(sessionId);
if (!session) return;
session.activeRunId = runId;

View File

@@ -68,9 +68,7 @@ export class AcpGatewayAgent implements Agent {
this.connection = connection;
this.gateway = gateway;
this.opts = opts;
this.log = opts.verbose
? (msg: string) => process.stderr.write(`[acp] ${msg}\n`)
: () => {};
this.log = opts.verbose ? (msg: string) => process.stderr.write(`[acp] ${msg}\n`) : () => {};
this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore;
}
@@ -207,9 +205,7 @@ export class AcpGatewayAgent implements Agent {
return {};
}
async setSessionMode(
params: SetSessionModeRequest,
): Promise<SetSessionModeResponse | void> {
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) {
throw new Error(`Session ${params.sessionId} not found`);
@@ -403,11 +399,7 @@ export class AcpGatewayAgent implements Agent {
});
}
private finishPrompt(
sessionId: string,
pending: PendingPrompt,
stopReason: StopReason,
): void {
private finishPrompt(sessionId: string, pending: PendingPrompt, stopReason: StopReason): void {
this.pendingPrompts.delete(sessionId);
this.sessionStore.clearActiveRun(sessionId);
pending.resolve({ stopReason });
@@ -429,5 +421,4 @@ export class AcpGatewayAgent implements Agent {
},
});
}
}

View File

@@ -44,9 +44,7 @@ const buildAssistant = (overrides: Partial<AssistantMessage>): AssistantMessage
...overrides,
});
const makeAttempt = (
overrides: Partial<EmbeddedRunAttemptResult>,
): EmbeddedRunAttemptResult => ({
const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunAttemptResult => ({
aborted: false,
timedOut: false,
promptError: null,
@@ -202,7 +200,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as { usageStats?: Record<string, { lastUsed?: number }> };
expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBeUndefined();
expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });

View File

@@ -565,8 +565,12 @@ export async function runEmbeddedAttempt(
// Check for existing image content to avoid duplicates across turns
const existingImageData = new Set(
msg.content
.filter((c): c is ImageContent =>
c != null && typeof c === "object" && c.type === "image" && typeof c.data === "string",
.filter(
(c): c is ImageContent =>
c != null &&
typeof c === "object" &&
c.type === "image" &&
typeof c.data === "string",
)
.map((c) => c.data),
);

View File

@@ -102,7 +102,8 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] {
}
// Pattern for [Image: source: /path/...] format from messaging systems
const messageImagePattern = /\[Image:\s*source:\s*([^\]]+\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|heif))\]/gi;
const messageImagePattern =
/\[Image:\s*source:\s*([^\]]+\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|heif))\]/gi;
while ((match = messageImagePattern.exec(prompt)) !== null) {
const raw = match[1]?.trim();
if (raw) addPathRef(raw);
@@ -111,8 +112,7 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] {
// Remote HTTP(S) URLs are intentionally ignored. Native image injection is local-only.
// Pattern for file:// URLs - treat as paths since loadWebMedia handles them
const fileUrlPattern =
/file:\/\/[^\s<>"'`\]]+\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|heif)/gi;
const fileUrlPattern = /file:\/\/[^\s<>"'`\]]+\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|heif)/gi;
while ((match = fileUrlPattern.exec(prompt)) !== null) {
const raw = match[0];
if (seen.has(raw.toLowerCase())) continue;
@@ -132,7 +132,8 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] {
// - ./relative/path.ext
// - ../parent/path.ext
// - ~/home/path.ext
const pathPattern = /(?:^|\s|["'`(])((\.\.?\/|[~/])[^\s"'`()[\]]*\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|heif))/gi;
const pathPattern =
/(?:^|\s|["'`(])((\.\.?\/|[~/])[^\s"'`()[\]]*\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|heif))/gi;
while ((match = pathPattern.exec(prompt)) !== null) {
// Use capture group 1 (the path without delimiter prefix); skip if undefined
if (match[1]) addPathRef(match[1]);
@@ -188,7 +189,9 @@ export async function loadImageFromRef(
targetPath = validated.resolved;
} catch (err) {
// Log the actual error for debugging (sandbox violation or other path error)
log.debug(`Native image: sandbox validation failed for ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`);
log.debug(
`Native image: sandbox validation failed for ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
}
@@ -219,7 +222,9 @@ export async function loadImageFromRef(
return { type: "image", data, mimeType };
} catch (err) {
// Log the actual error for debugging (size limits, network failures, etc.)
log.debug(`Native image: failed to load ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`);
log.debug(
`Native image: failed to load ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
}
@@ -255,9 +260,7 @@ function detectImagesFromHistory(messages: unknown[]): DetectedImageRef[] {
if (!Array.isArray(content)) return false;
return content.some(
(part) =>
part != null &&
typeof part === "object" &&
(part as { type?: string }).type === "image",
part != null && typeof part === "object" && (part as { type?: string }).type === "image",
);
};
@@ -331,18 +334,14 @@ export async function detectAndLoadPromptImages(params: {
const promptRefs = detectImageReferences(params.prompt);
// Detect images from conversation history (with message indices)
const historyRefs = params.historyMessages
? detectImagesFromHistory(params.historyMessages)
: [];
const historyRefs = params.historyMessages ? detectImagesFromHistory(params.historyMessages) : [];
// Deduplicate: if an image is in the current prompt, don't also load it from history.
// Current prompt images are passed via the `images` parameter to prompt(), while history
// images are injected into their original message positions. We don't want the same
// image loaded and sent twice (wasting tokens and potentially causing confusion).
const seenPaths = new Set(promptRefs.map((r) => r.resolved.toLowerCase()));
const uniqueHistoryRefs = historyRefs.filter(
(r) => !seenPaths.has(r.resolved.toLowerCase()),
);
const uniqueHistoryRefs = historyRefs.filter((r) => !seenPaths.has(r.resolved.toLowerCase()));
const allRefs = [...promptRefs, ...uniqueHistoryRefs];

View File

@@ -129,7 +129,9 @@ describe("image tool implicit imageModel config", () => {
});
const tool = createImageTool({ config: cfg, agentDir, modelHasVision: true });
expect(tool).not.toBeNull();
expect(tool?.description).toContain("Only use this tool when the image was NOT already provided");
expect(tool?.description).toContain(
"Only use this tool when the image was NOT already provided",
);
});
it("sandboxes image paths like the read tool", async () => {

View File

@@ -1,2 +1 @@
export { registerNodeCli } from "./node-cli/register.js";

View File

@@ -81,7 +81,10 @@ function buildNodeRuntimeHints(env: NodeJS.ProcessEnv = process.env): string[] {
return [];
}
function resolveNodeDefaults(opts: NodeDaemonInstallOptions, config: Awaited<ReturnType<typeof loadNodeHostConfig>>) {
function resolveNodeDefaults(
opts: NodeDaemonInstallOptions,
config: Awaited<ReturnType<typeof loadNodeHostConfig>>,
) {
const host = opts.host?.trim() || config?.gateway?.host || "127.0.0.1";
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
@@ -171,10 +174,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
}
const tlsFingerprint = opts.tlsFingerprint?.trim() || config?.gateway?.tlsFingerprint;
const tls =
Boolean(opts.tls) ||
Boolean(tlsFingerprint) ||
Boolean(config?.gateway?.tls);
const tls = Boolean(opts.tls) || Boolean(tlsFingerprint) || Boolean(config?.gateway?.tls);
const { programArguments, workingDirectory, environment, description } =
await buildNodeInstallPlan({
env: process.env,
@@ -495,9 +495,7 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
service.readCommand(process.env).catch(() => null),
service
.readRuntime(process.env)
.catch(
(err): GatewayServiceRuntime => ({ status: "unknown", detail: String(err) }),
),
.catch((err): GatewayServiceRuntime => ({ status: "unknown", detail: String(err) })),
]);
const payload = {

View File

@@ -24,8 +24,7 @@ export function registerNodeCli(program: Command) {
.description("Run a headless node host (system.run/system.which)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/node", "docs.clawd.bot/cli/node")}\n`,
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/node", "docs.clawd.bot/cli/node")}\n`,
);
node
@@ -40,9 +39,7 @@ export function registerNodeCli(program: Command) {
.action(async (opts) => {
const existing = await loadNodeHostConfig();
const host =
(opts.host as string | undefined)?.trim() ||
existing?.gateway?.host ||
"127.0.0.1";
(opts.host as string | undefined)?.trim() || existing?.gateway?.host || "127.0.0.1";
const port = parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18790);
await runNodeHost({
gatewayHost: host,

View File

@@ -387,7 +387,9 @@ export async function agentCommand(
provider: providerOverride,
model: modelOverride,
authProfileId,
authProfileIdSource: authProfileId ? sessionEntry?.authProfileOverrideSource : undefined,
authProfileIdSource: authProfileId
? sessionEntry?.authProfileOverrideSource
: undefined,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,

View File

@@ -206,7 +206,7 @@ export const handleBridgeEvent = async (
} else if (evt.event === "exec.finished") {
const exitLabel = timedOut ? "timeout" : `code ${exitCode ?? "?"}`;
text = `Exec finished (node=${nodeId}${runId ? ` id=${runId}` : ""}, ${exitLabel})`;
if (output) text += `\\n${output}`;
if (output) text += `\n${output}`;
} else {
text = `Exec denied (node=${nodeId}${runId ? ` id=${runId}` : ""}${reason ? `, ${reason}` : ""})`;
if (command) text += `: ${command}`;

View File

@@ -84,9 +84,7 @@ export const handleSystemBridgeMethods: BridgeMethodHandler = async (
eligibility: { remote: getRemoteSkillEligibility() },
});
const bins = Array.from(
new Set(
report.skills.flatMap((skill) => skill.requirements?.bins ?? []).filter(Boolean),
),
new Set(report.skills.flatMap((skill) => skill.requirements?.bins ?? []).filter(Boolean)),
);
return { ok: true, payloadJSON: JSON.stringify({ bins }) };
}

View File

@@ -308,9 +308,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const messageText = (message.text ?? "").trim();
const attachments = includeAttachments ? (message.attachments ?? []) : [];
// Filter to valid attachments with paths
const validAttachments = attachments.filter(
(entry) => entry?.original_path && !entry?.missing,
);
const validAttachments = attachments.filter((entry) => entry?.original_path && !entry?.missing);
const firstAttachment = validAttachments[0];
const mediaPath = firstAttachment?.original_path ?? undefined;
const mediaType = firstAttachment?.mime_type ?? undefined;

View File

@@ -7,4 +7,3 @@ export function buildNodeShellCommand(command: string, platform?: string | null)
}
return ["/bin/sh", "-lc", command];
}

View File

@@ -237,11 +237,10 @@ async function sipsApplyOrientation(buffer: Buffer, orientation: number): Promis
const input = path.join(dir, "in.jpg");
const output = path.join(dir, "out.jpg");
await fs.writeFile(input, buffer);
await runExec(
"/usr/bin/sips",
[...ops, input, "--out", output],
{ timeoutMs: 20_000, maxBuffer: 1024 * 1024 },
);
await runExec("/usr/bin/sips", [...ops, input, "--out", output], {
timeoutMs: 20_000,
maxBuffer: 1024 * 1024,
});
return await fs.readFile(output);
});
}

View File

@@ -47,7 +47,6 @@ type PendingRpc = {
timer?: NodeJS.Timeout;
};
function normalizeFingerprint(input: string): string {
return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
}
@@ -237,10 +236,7 @@ export class BridgeClient {
}
}
private handleFrame(frame: {
type?: string;
[key: string]: unknown;
}) {
private handleFrame(frame: { type?: string; [key: string]: unknown }) {
const type = String(frame.type ?? "");
switch (type) {
case "hello-ok": {

View File

@@ -71,4 +71,3 @@ export async function ensureNodeHostConfig(): Promise<NodeHostConfig> {
await saveNodeHostConfig(normalized);
return normalized;
}

View File

@@ -17,11 +17,7 @@ import { getMachineDisplayName } from "../infra/machine-name.js";
import { VERSION } from "../version.js";
import { BridgeClient } from "./bridge-client.js";
import {
ensureNodeHostConfig,
saveNodeHostConfig,
type NodeHostGatewayConfig,
} from "./config.js";
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
type NodeHostRunOptions = {
gatewayHost: string;
@@ -114,7 +110,9 @@ class SkillBinsCache {
}
}
function sanitizeEnv(overrides?: Record<string, string> | null): Record<string, string> | undefined {
function sanitizeEnv(
overrides?: Record<string, string> | null,
): Record<string, string> | undefined {
if (!overrides) return undefined;
const merged = { ...process.env } as Record<string, string>;
for (const [rawKey, value] of Object.entries(overrides)) {
@@ -132,7 +130,7 @@ function formatCommand(argv: string[]): string {
return argv
.map((arg) => {
const trimmed = arg.trim();
if (!trimmed) return "\"\"";
if (!trimmed) return '""';
const needsQuotes = /\s|"/.test(trimmed);
if (!needsQuotes) return trimmed;
return `"${trimmed.replace(/"/g, '\\"')}"`;
@@ -247,9 +245,7 @@ function resolveExecutable(bin: string, env?: Record<string, string>) {
}
async function handleSystemWhich(params: SystemWhichParams, env?: Record<string, string>) {
const bins = params.bins
.map((bin) => bin.trim())
.filter(Boolean);
const bins = params.bins.map((bin) => bin.trim()).filter(Boolean);
const found: Record<string, string> = {};
for (const bin of bins) {
const path = resolveExecutable(bin, env);
@@ -334,10 +330,11 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
});
const skillBins = new SkillBinsCache(async () => {
const res = await client.request("skills.bins", {});
const bins = Array.isArray(res?.bins)
? res.bins.map((bin: unknown) => String(bin))
: [];
const res = (await client.request("skills.bins", {})) as
| { bins?: unknown[] }
| null
| undefined;
const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : [];
return bins;
});

View File

@@ -1,7 +1,4 @@
import {
parseAgentSessionKey,
type ParsedAgentSessionKey,
} from "../sessions/session-key-utils.js";
import { parseAgentSessionKey, type ParsedAgentSessionKey } from "../sessions/session-key-utils.js";
export {
isAcpSessionKey,

View File

@@ -63,7 +63,10 @@ async function loadWebMediaInternal(
kind: MediaKind;
fileName?: string;
}): Promise<WebMediaResult> => {
const cap = maxBytes !== undefined ? Math.min(maxBytes, maxBytesForKind(params.kind)) : maxBytesForKind(params.kind);
const cap =
maxBytes !== undefined
? Math.min(maxBytes, maxBytesForKind(params.kind))
: maxBytesForKind(params.kind);
if (params.kind === "image") {
const isGif = params.contentType === "image/gif";
if (isGif || !optimizeImages) {