mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-13 10:14:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb81b72ff0 |
No files matched your search
@@ -1,18 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { enAiMessages } from './en/ai';
|
||||
import { esAiMessages } from './es/ai';
|
||||
import { ruAiMessages } from './ru/ai';
|
||||
import { zhCNAiMessages } from './zh-CN/ai';
|
||||
import { zhTWAiMessages } from './zh-TW/ai';
|
||||
|
||||
for (const [locale, messages] of Object.entries({
|
||||
en: enAiMessages, es: esAiMessages, ru: ruAiMessages,
|
||||
'zh-CN': zhCNAiMessages, 'zh-TW': zhTWAiMessages,
|
||||
})) {
|
||||
test(`${locale} translates the Catty iteration limit notice and Continue action`, () => {
|
||||
assert.ok(messages['ai.chat.iterationLimitReached']);
|
||||
assert.match(messages['ai.chat.iterationLimitReached'], /\{limit\}/);
|
||||
assert.ok(messages['ai.chat.continue']);
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enAiMessages: Messages = {
|
||||
'ai.chat.iterationLimitReached': 'Tool-loop limit ({limit} iterations) reached. You can continue in this chat. Running terminal jobs are not stopped.',
|
||||
'ai.chat.continue': 'Continue',
|
||||
'ai.chat.attachmentContentUnavailable': 'Content not available on this device',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esAiMessages: Messages = {
|
||||
'ai.chat.iterationLimitReached': 'Se alcanzó el límite del bucle de herramientas ({limit} iteraciones). Puedes continuar en este chat. Las tareas de terminal en ejecución no se detienen.',
|
||||
'ai.chat.continue': 'Continuar',
|
||||
'ai.chat.attachmentContentUnavailable': 'Contenido no disponible en este dispositivo',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruAiMessages: Messages = {
|
||||
'ai.chat.iterationLimitReached': 'Достигнут лимит цикла инструментов ({limit} итераций). Можно продолжить в этом чате. Запущенные задачи терминала не останавливаются.',
|
||||
'ai.chat.continue': 'Продолжить',
|
||||
'ai.chat.attachmentContentUnavailable': 'Содержимое недоступно на этом устройстве',
|
||||
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
|
||||
// AI Settings
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.iterationLimitReached': '已达到工具循环上限({limit} 次迭代)。你可以在此对话中继续,正在运行的终端任务不会停止。',
|
||||
'ai.chat.continue': '继续',
|
||||
'ai.chat.attachmentContentUnavailable': '附件内容未保留在此设备',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.iterationLimitReached': '已達到工具迴圈上限({limit} 次迭代)。你可以在此對話中繼續,正在執行的終端工作不會停止。',
|
||||
'ai.chat.continue': '繼續',
|
||||
'ai.chat.attachmentContentUnavailable': '附件內容未保留在此裝置',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
|
||||
@@ -55,7 +55,7 @@ interface AIChatPanelContentProps {
|
||||
contextUsage?: import('../application/state/useAgentCompactionUi').AgentContextUsage | null;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: (textOverride?: string) => void;
|
||||
handleSend: () => void;
|
||||
handleCompact: () => void;
|
||||
/** Rename a chat by id (history drawer edit affordance). Panel action, never agent content. */
|
||||
onRenameSession?: (sessionId: string, title: string) => void;
|
||||
@@ -211,7 +211,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
parked = false,
|
||||
sending = false,
|
||||
}) => {
|
||||
const handleContinue = React.useCallback(() => handleSend(t('ai.chat.continue')), [handleSend, t]);
|
||||
const mentionHosts = React.useMemo(
|
||||
() => terminalSessions.map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
@@ -346,7 +345,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onContinue={handleContinue}
|
||||
activeSessionId={activeSessionId}
|
||||
activeCompaction={activeCompaction}
|
||||
contextCompaction={activeSession?.contextCompaction}
|
||||
|
||||
@@ -1610,14 +1610,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
|
||||
const handleSend = useCallback(async (textOverride?: string) => {
|
||||
const handleSend = useCallback(async () => {
|
||||
const draft = currentDraftRef.current;
|
||||
const currentPanelView = panelViewRef.current;
|
||||
const currentSessionView = activeSessionRef.current;
|
||||
if (textOverride === undefined && !validateNoteMentions(draft?.attachments ?? [])) return;
|
||||
const trimmed = (textOverride ?? draft?.text ?? '').trim();
|
||||
if (!validateNoteMentions(draft?.attachments ?? [])) return;
|
||||
const trimmed = draft?.text.trim() ?? '';
|
||||
const sendScopeKey = scopeKey;
|
||||
const attachments = (textOverride === undefined ? draft?.attachments ?? [] : []).map((file) => ({
|
||||
const attachments = (draft?.attachments ?? []).map((file) => ({
|
||||
base64Data: file.base64Data,
|
||||
mediaType: file.mediaType,
|
||||
filename: file.filename,
|
||||
@@ -1645,12 +1645,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
if (sendAgentId !== 'catty' && !agentConfig) return;
|
||||
|
||||
const selectedSkillSlugs = textOverride === undefined ? draft?.selectedUserSkillSlugs ?? [] : [];
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
if (textOverride === undefined) flushDraftText();
|
||||
flushDraftText();
|
||||
const submittedText = draft?.text ?? '';
|
||||
const keepPendingAfterSend = () => {
|
||||
const pending = pendingComposerTextRef.current;
|
||||
@@ -1739,7 +1739,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const createdSession = createSession(scope, sendAgentId);
|
||||
sessionId = createdSession.id;
|
||||
currentSession = createdSession;
|
||||
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
sessionSendGateKey = sessionId;
|
||||
@@ -1770,7 +1770,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
@@ -1785,7 +1785,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
@@ -1798,7 +1798,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
@@ -645,64 +644,3 @@ test('fork is offered only for safe completed replies and hidden while streaming
|
||||
assert.equal((render(false).match(/Fork from here/g) ?? []).length, 1);
|
||||
assert.equal((render(true).match(/Fork from here/g) ?? []).length, 0);
|
||||
});
|
||||
|
||||
test('ChatMessageList renders a non-error iteration limit notice with a Continue action', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: 'poll', role: 'tool', content: '', timestamp: 1,
|
||||
toolResults: [{ toolCallId: 'poll-1', content: '{"jobId":"job-1","status":"running"}' }] },
|
||||
{ id: 'limit', role: 'assistant', content: '', timestamp: 2, iterationLimit: 20 },
|
||||
];
|
||||
const render = (isStreaming: boolean) => renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<ChatMessageList messages={messages} isStreaming={isStreaming} onContinue={() => {}} />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const markup = render(false);
|
||||
assert.match(markup, /Tool-loop limit \(20 iterations\) reached/);
|
||||
assert.match(markup, /<button[^>]*>Continue<\/button>/);
|
||||
assert.doesNotMatch(markup, /bg-destructive/);
|
||||
assert.match(render(true), /<button[^>]*disabled=""[^>]*>Continue<\/button>/);
|
||||
|
||||
const source = readFileSync(new URL('./ChatMessageList.tsx', import.meta.url), 'utf8');
|
||||
const panel = readFileSync(new URL('../AIChatPanelContent.tsx', import.meta.url), 'utf8');
|
||||
const sidePanel = readFileSync(new URL('../AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /onClick=\{onContinue\}/);
|
||||
assert.match(panel, /onContinue=\{handleContinue\}/);
|
||||
assert.match(panel, /handleContinue = React.useCallback\(\(\) => handleSend\(t\('ai.chat.continue'\)\)/);
|
||||
assert.match(sidePanel, /const handleSend = useCallback\(async \(textOverride\?: string\)/);
|
||||
assert.match(sidePanel, /const trimmed = \(textOverride \?\? draft\?\.text \?\? ''\)\.trim\(\)/);
|
||||
});
|
||||
|
||||
test('iteration limit Continue button invokes the provided send callback', async t => {
|
||||
const previousWindow = globalThis.window;
|
||||
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
|
||||
globalThis.window = { setTimeout, clearTimeout } as unknown as Window & typeof globalThis;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
t.after(() => {
|
||||
globalThis.window = previousWindow;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
});
|
||||
let continued = 0;
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<ChatMessageList
|
||||
messages={[{ id: 'limit', role: 'assistant', content: '', timestamp: 1, iterationLimit: 20 }]}
|
||||
onContinue={() => { continued += 1; }}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
const button = renderer!.root.findAllByType('button').find(node => node.children.includes('Continue'));
|
||||
assert.ok(button);
|
||||
assert.equal(button.props.disabled, false);
|
||||
await act(async () => { button.props.onClick(); });
|
||||
assert.equal(continued, 1);
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
});
|
||||
@@ -90,7 +90,6 @@ import ChatJumpNav from './ChatJumpNav';
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming?: boolean;
|
||||
onContinue?: () => void;
|
||||
/** Active chat session ID — used to filter standalone MCP approval blocks */
|
||||
activeSessionId?: string | null;
|
||||
activeCompaction?: ActiveCompactionUi | null;
|
||||
@@ -186,7 +185,6 @@ export function pruneResolvedApprovals(
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
messages,
|
||||
isStreaming,
|
||||
onContinue,
|
||||
activeSessionId,
|
||||
activeCompaction = null,
|
||||
contextCompaction,
|
||||
@@ -815,18 +813,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.iterationLimit != null && (
|
||||
<div role="status" className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm">
|
||||
<p>{t('ai.chat.iterationLimitReached', { limit: message.iterationLimit })}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
disabled={!!isStreaming || !onContinue}
|
||||
className="mt-2 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>{t('ai.chat.continue')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error info */}
|
||||
{message.errorInfo && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
|
||||
@@ -1093,7 +1079,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
|
||||
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
|
||||
if (prev.isStreaming !== next.isStreaming) return false;
|
||||
if (prev.onContinue !== next.onContinue) return false;
|
||||
if (prev.activeSessionId !== next.activeSessionId) return false;
|
||||
if (prev.notes !== next.notes) return false;
|
||||
if (prev.hosts !== next.hosts) return false;
|
||||
@@ -1122,7 +1107,6 @@ function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps
|
||||
p.statusText !== n.statusText ||
|
||||
p.executionStatus !== n.executionStatus ||
|
||||
p.errorInfo !== n.errorInfo ||
|
||||
p.iterationLimit !== n.iterationLimit ||
|
||||
p.toolCalls !== n.toolCalls ||
|
||||
p.toolResults !== n.toolResults ||
|
||||
p.agentActivities !== n.agentActivities ||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { describeHostKey } = require("../electron/bridges/hostKeyVerifier.cjs");
|
||||
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("../electron/bridges/fixtures/hostCertificates.test.fixture.cjs");
|
||||
|
||||
import type { KnownHost } from "./models";
|
||||
import {
|
||||
@@ -144,55 +139,6 @@ test("fingerprintFromPublicKey returns empty string on missing input", () => {
|
||||
|
||||
// --- Migration --------------------------------------------------------------
|
||||
|
||||
for (const options of [
|
||||
{ type: "ed25519" },
|
||||
{ type: "rsa", bits: 2048 },
|
||||
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
|
||||
{ type: "ed25519", sk: true },
|
||||
{ type: "ecdsa", bits: 256, sk: true },
|
||||
]) {
|
||||
test(`renderer certificate fingerprint and migration agree with verifier: ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t, options);
|
||||
const expected = describeHostKey(first.blob).fingerprint;
|
||||
assert.equal(fingerprintFromPublicKey(first.line), expected);
|
||||
assert.equal(expected, first.publicKeyFingerprint);
|
||||
assert.equal(fingerprintFromPublicKey(renewed.line), expected);
|
||||
assert.notEqual(fingerprintFromPublicKey(different.line), expected);
|
||||
assert.equal(fingerprintFromPublicKey(first.plain.line), expected);
|
||||
|
||||
const legacy = knownHost({
|
||||
keyType: first.keyType,
|
||||
publicKey: first.line,
|
||||
fingerprint: crypto.createHash("sha256").update(first.blob).digest("base64").replace(/=+$/g, ""),
|
||||
});
|
||||
const migrated = normalizeKnownHost(legacy);
|
||||
assert.equal(migrated.fingerprint, expected);
|
||||
assert.equal(migrated.keyType, first.keyType);
|
||||
assert.equal(migrated.publicKey, first.line);
|
||||
assert.equal(normalizeKnownHost(migrated), migrated, "migration is idempotent");
|
||||
assert.equal(normalizeKnownHost({ ...legacy, fingerprint: undefined }).fingerprint, expected);
|
||||
assert.equal(normalizeKnownHost({ ...legacy, keyType: "" }).keyType, first.keyType);
|
||||
const unrelated = { ...legacy, fingerprint: "unrelated-explicit-fingerprint" };
|
||||
assert.equal(normalizeKnownHost(unrelated), unrelated);
|
||||
const opaque = { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` };
|
||||
assert.equal(normalizeKnownHost(opaque), opaque);
|
||||
const fingerprintOnly = { ...legacy, publicKey: undefined };
|
||||
assert.equal(normalizeKnownHost(fingerprintOnly), fingerprintOnly);
|
||||
});
|
||||
}
|
||||
|
||||
test("renderer fingerprints v00 certificates and falls back for unparseable certificates", () => {
|
||||
const cert = legacyCertificate("ssh-rsa", ["exponent", "modulus"]);
|
||||
assert.equal(fingerprintFromPublicKey(cert.line), describeHostKey(cert.plain).fingerprint);
|
||||
const malformed = Buffer.concat([
|
||||
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
|
||||
]);
|
||||
const line = `ssh-ed25519-cert-v01@openssh.com ${malformed.toString("base64")}`;
|
||||
const fullHash = crypto.createHash("sha256").update(malformed).digest("base64").replace(/=+$/g, "");
|
||||
assert.equal(fingerprintFromPublicKey(line), fullHash);
|
||||
assert.equal(fingerprintFromPublicKey(line), describeHostKey(malformed).fingerprint);
|
||||
});
|
||||
|
||||
test("normalizeKnownHost backfills fingerprint when only publicKey is stored", () => {
|
||||
const rawKey = makeRawPublicKey("ssh-ed25519");
|
||||
const base64Body = rawKey.toString("base64");
|
||||
|
||||
+6
-15
@@ -1,5 +1,4 @@
|
||||
import type { KnownHost } from "./models";
|
||||
import { extractCertificatePublicKey } from "@/domain/sshPublicKey.shared.cjs";
|
||||
|
||||
const normalizeHost = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
@@ -133,7 +132,7 @@ export const fingerprintFromPublicKey = (publicKey: string | undefined | null):
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length >= 2 && SSH_KEY_TYPE_PREFIX.test(parts[0])) {
|
||||
const bytes = base64Decode(parts[1]);
|
||||
if (bytes) return stripPadding(base64Encode(sha256Bytes(extractCertificatePublicKey(bytes) || bytes)));
|
||||
if (bytes) return stripPadding(base64Encode(sha256Bytes(bytes)));
|
||||
}
|
||||
|
||||
return stripPadding(trimmed);
|
||||
@@ -146,9 +145,9 @@ const extractKeyTypeFromPublicKey = (publicKey: string | undefined | null): stri
|
||||
};
|
||||
|
||||
/**
|
||||
* Backfill missing `fingerprint` / `keyType` and migrate full-certificate
|
||||
* fingerprints to the underlying key when the certificate body is available.
|
||||
* Returns the same reference when nothing changes so callers can skip
|
||||
* Backfill missing `fingerprint` / `keyType` on a stored record so the host
|
||||
* verifier can match it without falling back to the brittle re-derivation
|
||||
* path. Returns the same reference when nothing changes so callers can skip
|
||||
* persistence writes and React re-renders.
|
||||
*/
|
||||
export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
|
||||
@@ -157,17 +156,9 @@ export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
|
||||
&& knownHost.keyType.length > 0
|
||||
&& knownHost.keyType !== "unknown";
|
||||
|
||||
const parts = knownHost.publicKey?.trim().split(/\s+/) ?? [];
|
||||
const certBytes = /-cert-v0[01]@openssh\.com$/.test(parts[0]) && parts[1]
|
||||
? base64Decode(parts[1]) : null;
|
||||
const legacyCertFingerprint = certBytes && extractCertificatePublicKey(certBytes)
|
||||
? stripPadding(base64Encode(sha256Bytes(certBytes))) : "";
|
||||
const migrateCertificate = legacyCertFingerprint
|
||||
&& stripPadding((knownHost.fingerprint ?? "").replace(/^SHA256:/i, "")) === legacyCertFingerprint;
|
||||
if (hasFingerprint && hasKeyType) return knownHost;
|
||||
|
||||
if (hasFingerprint && hasKeyType && !migrateCertificate) return knownHost;
|
||||
|
||||
const derivedFingerprint = hasFingerprint && !migrateCertificate
|
||||
const derivedFingerprint = hasFingerprint
|
||||
? knownHost.fingerprint!
|
||||
: fingerprintFromPublicKey(knownHost.publicKey);
|
||||
const derivedKeyType = hasKeyType
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// Extract only the public-key wire blob for fingerprinting, never CA trust or
|
||||
// certificate validation. ssh2's getPublicSSH() preserves the full certificate,
|
||||
// and its internal base-type helper is not exported. Keep this byte-only helper
|
||||
// shared by Electron and the renderer so migration uses exactly the same bytes.
|
||||
// Layouts: OpenSSH PROTOCOL.certkeys (v00/v01) and PROTOCOL.u2f (SK).
|
||||
function extractCertificatePublicKey(blob) {
|
||||
let offset = 0;
|
||||
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
|
||||
const skip = (length) => {
|
||||
if (offset + length > blob.length) throw new Error("Truncated SSH key");
|
||||
offset += length;
|
||||
};
|
||||
const readString = () => {
|
||||
skip(4);
|
||||
const length = view.getUint32(offset - 4, false);
|
||||
const start = offset;
|
||||
skip(length);
|
||||
return blob.subarray(start, offset);
|
||||
};
|
||||
const ascii = (bytes) => String.fromCharCode(...bytes);
|
||||
|
||||
try {
|
||||
const typeBytes = readString();
|
||||
if (typeBytes.length > 128) return null;
|
||||
const match = ascii(typeBytes).match(/^(.*)-cert-v0([01])@openssh\.com$/);
|
||||
if (!match) return null;
|
||||
let baseType = match[1];
|
||||
const v01 = match[2] === "1";
|
||||
if (/^rsa-sha2-(256|512)$/.test(baseType)) baseType = "ssh-rsa";
|
||||
if (baseType === "sk-ssh-ed25519" || baseType === "sk-ecdsa-sha2-nistp256") {
|
||||
baseType += "@openssh.com";
|
||||
}
|
||||
let fields;
|
||||
switch (baseType) {
|
||||
case "ssh-rsa": fields = 2; break;
|
||||
case "ssh-dss": fields = 4; break;
|
||||
case "ssh-ed25519": fields = 1; break;
|
||||
case "sk-ssh-ed25519@openssh.com": fields = 2; break;
|
||||
case "sk-ecdsa-sha2-nistp256@openssh.com": fields = 3; break;
|
||||
case "ecdsa-sha2-nistp256":
|
||||
case "ecdsa-sha2-nistp384":
|
||||
case "ecdsa-sha2-nistp521": fields = 2; break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
// v00 put its nonce after the constraints, not before the key material.
|
||||
if (v01) readString();
|
||||
const keyStart = offset;
|
||||
for (let i = 0; i < fields; i += 1) {
|
||||
const field = readString();
|
||||
const isApplication = baseType.startsWith("sk-") && i === fields - 1;
|
||||
if (!field.length && !isApplication) return null;
|
||||
if (i === 0 && baseType.includes("ed25519") && field.length !== 32) return null;
|
||||
if (i === 0 && baseType.includes("ecdsa")) {
|
||||
const curve = baseType.replace(/^sk-/, "").replace(/@openssh\.com$/, "").replace(/^ecdsa-sha2-/, "");
|
||||
if (field.length > 8 || ascii(field) !== curve) return null;
|
||||
}
|
||||
}
|
||||
const keyEnd = offset;
|
||||
// Check the outer envelope too; truncated/unsupported certificates retain
|
||||
// their full-blob fingerprint rather than gaining an inner-key match.
|
||||
if (v01) skip(8); // serial
|
||||
skip(4); // host/user type
|
||||
readString(); // key ID
|
||||
readString(); // principals
|
||||
skip(16); // validity
|
||||
readString(); // critical options (v00 constraints)
|
||||
readString(); // extensions (v00 nonce)
|
||||
readString(); // reserved
|
||||
if (!readString().length || !readString().length || offset !== blob.length) return null;
|
||||
|
||||
const result = new Uint8Array(4 + baseType.length + keyEnd - keyStart);
|
||||
new DataView(result.buffer).setUint32(0, baseType.length, false);
|
||||
for (let i = 0; i < baseType.length; i += 1) result[4 + i] = baseType.charCodeAt(i);
|
||||
result.set(blob.subarray(keyStart, keyEnd), 4 + baseType.length);
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
exports.extractCertificatePublicKey = extractCertificatePublicKey;
|
||||
@@ -84,7 +84,6 @@ module.exports = {
|
||||
// is excluded below, so the shared TOTP runtime must be listed.
|
||||
'domain/totp.shared.cjs',
|
||||
'domain/totpPrompt.shared.cjs',
|
||||
'domain/sshPublicKey.shared.cjs',
|
||||
'lib/**/*.cjs',
|
||||
'lib/**/*.json',
|
||||
'skills/**/*',
|
||||
|
||||
@@ -24,6 +24,21 @@ const UI_DISALLOWED_TOOLS = ["AskUserQuestion"];
|
||||
const MCP_MODE_TOOLS = [];
|
||||
const SKILLS_MODE_TOOLS = ["Bash"];
|
||||
|
||||
// Deduped built-in catalog from @tencent-ai/agent-sdk 0.3.258 cli/product.json.
|
||||
const CODEBUDDY_BUILTIN_TOOLS = [
|
||||
"Agent", "Bash", "PowerShell", "Glob", "Grep", "Read", "Edit", "Write",
|
||||
"NotebookEdit", "WebFetch", "ListMcpResources", "ReadMcpResource", "WaitForMcpServers",
|
||||
"TaskCreate", "TaskGet", "TaskUpdate", "TaskList", "WebSearch", "EnterPlanMode",
|
||||
"ExitPlanMode", "KillShell", "TaskStop", "TaskOutput", "SlashCommand", "Skill",
|
||||
"SkillManage", "AskUserQuestion", "AskUserForStructuredInput", "LSP", "StructuredOutput",
|
||||
"ToolSearch", "DeferExecuteTool", "ImageGen", "ImageEdit", "VideoGen", "Artifact",
|
||||
"ArtifactControl", "ComputerUse", "TeamCreate", "TeamDelete", "SendMessage",
|
||||
"SendUserMessage", "EnterWorktree", "LeaveWorktree", "CronCreate", "CronDelete",
|
||||
"CronList", "DelegateTool", "WeChatReply", "WeComReply", "PushNotification",
|
||||
"ReportFindings", "Workflow", "Monitor", "REPL", "A2AGetAgentCard", "A2ASendMessage",
|
||||
"A2AGetTask", "A2ACancelTask",
|
||||
];
|
||||
|
||||
const CODEBUDDY_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||
|
||||
function isCodebuddyImageAttachment(attachment) {
|
||||
@@ -261,18 +276,19 @@ function buildCodebuddyQueryOptions({
|
||||
allowDangerouslySkipPermissions: true,
|
||||
extraArgs: { "dangerously-skip-permissions": null },
|
||||
mcpServers: toSdkMcpServers(injectedMcpServers),
|
||||
tools: builtinTools,
|
||||
// `tools` is the built-in tool whitelist. In mcp mode it is [], so CodeBuddy
|
||||
// built-ins stay disabled while injected Netcatty MCP tools remain visible.
|
||||
// Do not mirror that empty list into allowedTools: the SDK treats
|
||||
// allowedTools as an auto-approval list, and allowedTools: [] prevents MCP
|
||||
// tool calls from running under bypassPermissions.
|
||||
disallowedTools: [...UI_DISALLOWED_TOOLS],
|
||||
// Block built-ins explicitly in mcp mode: tools: [] becomes --tools "",
|
||||
// which also hides injected MCP tools in CodeBuddy CLI v2.183.0.
|
||||
// Leave allowedTools unset: an empty auto-approval list prevents MCP tool
|
||||
// calls from running under bypassPermissions.
|
||||
disallowedTools: builtinTools.length > 0
|
||||
? [...UI_DISALLOWED_TOOLS]
|
||||
: [...new Set([...UI_DISALLOWED_TOOLS, ...CODEBUDDY_BUILTIN_TOOLS])],
|
||||
// Keep the SDK isolated from user/project settings so local hooks, plugins,
|
||||
// or extra MCP servers cannot expand Netcatty's controlled tool boundary.
|
||||
settingSources: [],
|
||||
env,
|
||||
};
|
||||
if (builtinTools.length > 0) options.tools = builtinTools;
|
||||
const selection = splitCodebuddyModelSelection(model);
|
||||
if (selection.model) options.model = selection.model;
|
||||
if (abortController) options.abortController = abortController;
|
||||
|
||||
@@ -59,15 +59,51 @@ test("buildCodebuddyQueryOptions wires SDK options in isolated mode", () => {
|
||||
assert.equal(opts.pathToCodebuddyCode, "/opt/codebuddy/bin/codebuddy");
|
||||
assert.equal(opts.abortController, ac);
|
||||
assert.equal(opts.resume, "sess-1");
|
||||
assert.deepEqual(opts.tools, []);
|
||||
// allowedTools must stay unset in mcp mode: tools:[] disables built-ins, while
|
||||
assert.ok(!("tools" in opts));
|
||||
// allowedTools:[] would prevent injected Netcatty MCP tools from running.
|
||||
assert.ok(!("allowedTools" in opts));
|
||||
assert.ok(opts.disallowedTools.includes("Bash"));
|
||||
assert.ok(opts.disallowedTools.includes("AskUserQuestion"));
|
||||
assert.equal(opts.mcpServers["netcatty-remote-hosts"].type, "stdio");
|
||||
assert.deepEqual(opts.mcpServers["netcatty-remote-hosts"].env, { NETCATTY_MCP_PORT: "1" });
|
||||
});
|
||||
|
||||
test("mcp and default mode block built-ins while preserving injected MCP servers", () => {
|
||||
for (const toolIntegrationMode of ["mcp", undefined]) {
|
||||
const opts = buildCodebuddyQueryOptions({
|
||||
toolIntegrationMode,
|
||||
injectedMcpServers: [{ name: "netcatty", command: "/abs/electron" }],
|
||||
});
|
||||
assert.ok(!("tools" in opts));
|
||||
assert.ok(!("allowedTools" in opts));
|
||||
assert.equal(opts.permissionMode, "bypassPermissions");
|
||||
for (const name of ["Bash", "PowerShell", "Read", "Edit", "Write", "Agent", "Skill", "AskUserQuestion"]) {
|
||||
assert.ok(opts.disallowedTools.includes(name), `${name} must stay disabled`);
|
||||
}
|
||||
assert.equal(opts.mcpServers.netcatty.command, "/abs/electron");
|
||||
}
|
||||
});
|
||||
|
||||
test("mcp mode omits --tools under the SDK serialization rule", () => {
|
||||
for (const toolIntegrationMode of ["mcp", undefined]) {
|
||||
const opts = buildCodebuddyQueryOptions({ toolIntegrationMode });
|
||||
const args = [];
|
||||
// @tencent-ai/agent-sdk lib/transport/process-transport.js
|
||||
if (opts.tools !== undefined) {
|
||||
args.push("--tools", opts.tools.join(","));
|
||||
}
|
||||
assert.deepEqual(args, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("skills mode keeps the Bash whitelist and UI exclusions", () => {
|
||||
const opts = buildCodebuddyQueryOptions({ toolIntegrationMode: "skills" });
|
||||
assert.deepEqual(opts.tools, ["Bash"]);
|
||||
assert.deepEqual(opts.disallowedTools, ["AskUserQuestion"]);
|
||||
assert.ok(!opts.disallowedTools.includes("Bash"));
|
||||
assert.ok(!("allowedTools" in opts));
|
||||
});
|
||||
|
||||
test("built-in tools are mode-aware", () => {
|
||||
assert.deepEqual(codebuddyBuiltinTools("mcp"), []);
|
||||
assert.deepEqual(codebuddyBuiltinTools(undefined), []);
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
let sshKeygenSkip = false;
|
||||
try {
|
||||
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") sshKeygenSkip = "ssh-keygen is not installed";
|
||||
}
|
||||
|
||||
const sshString = (value) => {
|
||||
const body = Buffer.from(value);
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(body.length);
|
||||
return Buffer.concat([length, body]);
|
||||
};
|
||||
|
||||
// Real local certificates, with no server/network or security-key device.
|
||||
function mintHostCertificates(t, { type = "ed25519", bits, sk = false, user = false } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(__dirname, ".hostcert-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const keygen = (...args) => execFileSync("ssh-keygen", ["-q", ...args], { stdio: "pipe" });
|
||||
const ca = path.join(dir, "ca");
|
||||
keygen("-t", "ed25519", "-N", "", "-f", ca);
|
||||
const readKey = (file) => {
|
||||
const line = fs.readFileSync(file, "utf8").trim();
|
||||
const [keyType, body] = line.split(/\s+/);
|
||||
return { line, keyType, blob: Buffer.from(body, "base64") };
|
||||
};
|
||||
const mint = (name, validity, serial) => {
|
||||
const host = path.join(dir, name);
|
||||
if (!fs.existsSync(host)) {
|
||||
keygen("-t", type, ...(bits ? ["-b", String(bits)] : []), "-N", "", "-f", host);
|
||||
if (sk) {
|
||||
const plain = readKey(`${host}.pub`);
|
||||
const keyMaterial = plain.blob.subarray(4 + plain.keyType.length);
|
||||
const skType = `sk-${plain.keyType}@openssh.com`;
|
||||
const blob = Buffer.concat([sshString(skType), keyMaterial, sshString("ssh:netcatty-test")]);
|
||||
fs.writeFileSync(`${host}.pub`, `${skType} ${blob.toString("base64")}\n`);
|
||||
}
|
||||
}
|
||||
keygen("-s", ca, "-I", "netcatty-hostcert", ...(user ? [] : ["-h"]),
|
||||
"-n", "127.0.0.1", "-V", validity, "-z", String(serial), `${host}.pub`);
|
||||
const cert = readKey(`${host}-cert.pub`);
|
||||
const listing = execFileSync("ssh-keygen", ["-L", "-f", `${host}-cert.pub`], { encoding: "utf8" });
|
||||
const publicKeyFingerprint = listing.match(/Public key: .* SHA256:(\S+)/)?.[1];
|
||||
if (!publicKeyFingerprint) throw new Error("Missing ssh-keygen certificate fingerprint");
|
||||
return { ...cert, publicKeyFingerprint, plain: readKey(`${host}.pub`) };
|
||||
};
|
||||
return {
|
||||
first: mint("host", "20250101:20260101", 1),
|
||||
renewed: mint("host", "20260101:20270101", 2),
|
||||
different: mint("other", "20260101:20270101", 3),
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy v00 is no longer emitted by ssh-keygen. This follows the original
|
||||
// PROTOCOL.certkeys layout: key material first, nonce after constraints.
|
||||
function legacyCertificate(keyType, keyFields) {
|
||||
const blob = Buffer.concat([
|
||||
sshString(`${keyType}-cert-v00@openssh.com`),
|
||||
...keyFields.map(sshString),
|
||||
Buffer.from([0, 0, 0, 2]),
|
||||
sshString("legacy"), sshString(""), Buffer.alloc(16),
|
||||
sshString(""), sshString("nonce"), sshString(""),
|
||||
sshString("signing key"), sshString("signature"),
|
||||
]);
|
||||
return {
|
||||
blob,
|
||||
plain: Buffer.concat([sshString(keyType), ...keyFields.map(sshString)]),
|
||||
line: `${keyType}-cert-v00@openssh.com ${blob.toString("base64")}`,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip };
|
||||
@@ -1,7 +1,6 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { utils: sshUtils } = require("ssh2");
|
||||
const { extractCertificatePublicKey } = require("../../domain/sshPublicKey.shared.cjs");
|
||||
|
||||
const REQUEST_TTL_MS = 2 * 60 * 1000;
|
||||
const hostKeyRequests = new Map();
|
||||
@@ -66,9 +65,8 @@ const fingerprintFromPublicKey = (publicKey) => {
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
|
||||
try {
|
||||
const blob = Buffer.from(parts[1], "base64");
|
||||
return crypto.createHash("sha256")
|
||||
.update(extractCertificatePublicKey(blob) || blob)
|
||||
.update(Buffer.from(parts[1], "base64"))
|
||||
.digest("base64")
|
||||
.replace(/=+$/g, "");
|
||||
} catch {
|
||||
@@ -79,16 +77,7 @@ const fingerprintFromPublicKey = (publicKey) => {
|
||||
};
|
||||
|
||||
const getKnownHostFingerprint = (knownHost) => {
|
||||
const stored = normalizeFingerprint(knownHost?.fingerprint);
|
||||
const parts = knownHost?.publicKey?.trim().split(/\s+/) || [];
|
||||
if (parts.length >= 2 && /-cert-v0[01]@openssh\.com$/.test(parts[0])) {
|
||||
const blob = Buffer.from(parts[1], "base64");
|
||||
// Only replace an explicit fingerprint if it is the legacy full-cert hash.
|
||||
// Fingerprint-only records cannot be migrated without the certificate body.
|
||||
const legacy = crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
|
||||
if (!stored || stored === legacy) return fingerprintFromPublicKey(knownHost.publicKey);
|
||||
}
|
||||
return stored
|
||||
return normalizeFingerprint(knownHost?.fingerprint)
|
||||
|| fingerprintFromPublicKey(knownHost?.publicKey);
|
||||
};
|
||||
|
||||
@@ -145,7 +134,7 @@ const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, finger
|
||||
const describeHostKey = (rawKey) => {
|
||||
const key = Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || "");
|
||||
const fingerprint = crypto.createHash("sha256")
|
||||
.update(extractCertificatePublicKey(key) || key)
|
||||
.update(key)
|
||||
.digest("base64")
|
||||
.replace(/=+$/g, "");
|
||||
let keyType = "unknown";
|
||||
@@ -160,9 +149,7 @@ const describeHostKey = (rawKey) => {
|
||||
try {
|
||||
const parsed = sshUtils.parseKey(key);
|
||||
const parsedKey = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
// Keep the presented certificate identity, even if a parser returns its
|
||||
// base algorithm or underlying public key as metadata.
|
||||
if (parsedKey && !(parsedKey instanceof Error) && !/-cert-v0[01]@openssh\.com$/.test(keyType)) {
|
||||
if (parsedKey && !(parsedKey instanceof Error)) {
|
||||
keyType = parsedKey.type || keyType;
|
||||
const publicSsh = parsedKey.getPublicSSH?.();
|
||||
if (publicSsh) publicKey = publicSsh.toString("utf8");
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("./fixtures/hostCertificates.test.fixture.cjs");
|
||||
|
||||
const {
|
||||
classifyHostKey,
|
||||
createHostVerifier,
|
||||
describeHostKey,
|
||||
getKnownHostFingerprint,
|
||||
handleResponse,
|
||||
normalizeFingerprint,
|
||||
} = require("./hostKeyVerifier.cjs");
|
||||
@@ -19,84 +17,6 @@ const makeRawPublicKey = (keyType, body = "trusted imported host key") => {
|
||||
return Buffer.concat([length, type, Buffer.from(body)]);
|
||||
};
|
||||
|
||||
const hashKey = (blob) => crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
|
||||
|
||||
test("host certificate renewals trust the inner key, including legacy saved fingerprints", { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t);
|
||||
const original = describeHostKey(first.blob);
|
||||
const live = describeHostKey(renewed.blob);
|
||||
const changed = describeHostKey(different.blob);
|
||||
assert.notDeepEqual(first.blob, renewed.blob);
|
||||
assert.equal(original.fingerprint, first.publicKeyFingerprint);
|
||||
assert.equal(live.fingerprint, original.fingerprint);
|
||||
assert.notEqual(changed.fingerprint, live.fingerprint);
|
||||
assert.equal(original.keyType, "ssh-ed25519-cert-v01@openssh.com");
|
||||
assert.equal(original.publicKey, `${first.keyType} ${first.blob.toString("base64")}`);
|
||||
assert.equal(describeHostKey(first.plain.blob).fingerprint, hashKey(first.plain.blob));
|
||||
|
||||
const stored = { id: "kh-cert", hostname: "cert.local", port: 2222, ...original };
|
||||
const classify = (key, record = stored) => classifyHostKey({
|
||||
knownHosts: [record], hostname: "cert.local", port: 2222, ...key,
|
||||
});
|
||||
assert.equal(classify(live).status, "trusted");
|
||||
assert.equal(classify(changed).status, "changed");
|
||||
assert.equal(classify(changed, { ...stored, keyType: "ssh-rsa" }).status, "unknown");
|
||||
|
||||
const legacy = { ...stored, fingerprint: hashKey(first.blob) };
|
||||
assert.notEqual(legacy.fingerprint, live.fingerprint);
|
||||
assert.equal(getKnownHostFingerprint(legacy), live.fingerprint);
|
||||
assert.equal(classify(live, legacy).status, "trusted");
|
||||
assert.equal(classify(changed, legacy).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, publicKey: undefined }).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` }).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, fingerprint: "unrelated-explicit-fingerprint" }).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, fingerprint: undefined }).status, "trusted");
|
||||
|
||||
// Renewal is trusted on reconnect, but changing the raw certificate during
|
||||
// an established connection must still be rejected synchronously on rekey.
|
||||
const verifier = createHostVerifier({ hostname: stored.hostname, port: stored.port, knownHosts: [legacy] });
|
||||
const results = [];
|
||||
verifier(first.blob, (accepted) => results.push(accepted));
|
||||
verifier(Buffer.from(first.blob), (accepted) => results.push(accepted));
|
||||
verifier(renewed.blob, (accepted) => results.push(accepted));
|
||||
assert.deepEqual(results, [true, true, false]);
|
||||
});
|
||||
|
||||
for (const options of [
|
||||
{ type: "rsa", bits: 2048 },
|
||||
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
|
||||
{ type: "ed25519", sk: true },
|
||||
{ type: "ecdsa", bits: 256, sk: true },
|
||||
{ type: "ed25519", user: true },
|
||||
]) {
|
||||
test(`certificate fingerprint matches ssh-keygen for ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t, options);
|
||||
assert.equal(describeHostKey(first.blob).fingerprint, first.publicKeyFingerprint);
|
||||
assert.equal(describeHostKey(first.blob).fingerprint, hashKey(first.plain.blob));
|
||||
assert.equal(describeHostKey(renewed.blob).fingerprint, first.publicKeyFingerprint);
|
||||
assert.notEqual(describeHostKey(different.blob).fingerprint, first.publicKeyFingerprint);
|
||||
});
|
||||
}
|
||||
|
||||
test("v00 certificates extract the key before the nonce", () => {
|
||||
for (const [type, fields] of [["ssh-rsa", ["exponent", "modulus"]], ["ssh-dss", ["p", "q", "g", "y"]]]) {
|
||||
const cert = legacyCertificate(type, fields);
|
||||
assert.equal(describeHostKey(cert.blob).fingerprint, hashKey(cert.plain));
|
||||
assert.equal(describeHostKey(cert.blob).keyType, `${type}-cert-v00@openssh.com`);
|
||||
}
|
||||
});
|
||||
|
||||
test("malformed and unsupported certificates keep their full-blob fingerprint", () => {
|
||||
const truncated = Buffer.concat([
|
||||
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
|
||||
]);
|
||||
const badLength = Buffer.concat([sshString("ssh-ed25519-cert-v01@openssh.com"), Buffer.from([255, 255, 255, 255])]);
|
||||
const unsupported = Buffer.concat([sshString("unknown-cert-v01@openssh.com"), sshString("nonce")]);
|
||||
for (const blob of [truncated, badLength, unsupported]) {
|
||||
assert.equal(describeHostKey(blob).fingerprint, hashKey(blob));
|
||||
}
|
||||
});
|
||||
|
||||
test("classifyHostKey prompts for unknown hosts", () => {
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [],
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
* leaves the Mosh stats bar empty (graceful degradation), never weakens
|
||||
* security.
|
||||
*/
|
||||
const { extractCertificatePublicKey } = require("../../../domain/sshPublicKey.shared.cjs");
|
||||
|
||||
function createSystemKnownHostsApi(ctx) {
|
||||
const { fs, path, os, crypto, log } = ctx;
|
||||
|
||||
@@ -66,7 +64,7 @@ function createSystemKnownHostsApi(ctx) {
|
||||
}
|
||||
if (blob.length === 0) return "";
|
||||
return stripFingerprintPadding(
|
||||
crypto.createHash("sha256").update(extractCertificatePublicKey(blob) || blob).digest("base64"),
|
||||
crypto.createHash("sha256").update(blob).digest("base64"),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -178,7 +176,7 @@ function createSystemKnownHostsApi(ctx) {
|
||||
|
||||
// Parse one known_hosts line into { revoked, certAuthority, hostField,
|
||||
// keyType, fingerprint } or null when it is a comment / blank / malformed.
|
||||
// Certificates pin their underlying key, just like the live verifier.
|
||||
// The fingerprint is the SHA-256 of the line's key blob.
|
||||
const parseKnownHostsLine = (rawLine) => {
|
||||
const line = String(rawLine || "").trim();
|
||||
if (!line || line.startsWith("#")) return null;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { mintHostCertificates, sshKeygenSkip } = require("../fixtures/hostCertificates.test.fixture.cjs");
|
||||
const { describeHostKey } = require("../hostKeyVerifier.cjs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
@@ -61,22 +59,6 @@ function hashedHostField(token, salt = crypto.randomBytes(20)) {
|
||||
|
||||
const HOME_KH = path.join(os.homedir(), ".ssh", "known_hosts");
|
||||
|
||||
test("system certificate entries pin the inner key across renewals without CA trust", { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t);
|
||||
const { api } = makeApi({ [HOME_KH]: `example.com ${first.line}\n` });
|
||||
const liveFingerprint = describeHostKey(renewed.blob).fingerprint;
|
||||
assert.equal(api.parseKnownHostsLine(`example.com ${first.line}`).fingerprint, liveFingerprint);
|
||||
assert.equal(api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), true);
|
||||
assert.equal(api.isHostKeyTrustedBySystem({
|
||||
hostname: "example.com", fingerprint: describeHostKey(different.blob).fingerprint,
|
||||
}), false);
|
||||
|
||||
const revoked = makeApi({ [HOME_KH]: `example.com ${first.line}\n@revoked example.com ${renewed.line}\n` });
|
||||
assert.equal(revoked.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
|
||||
const ca = makeApi({ [HOME_KH]: `@cert-authority example.com ${first.line}\n` });
|
||||
assert.equal(ca.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
|
||||
});
|
||||
|
||||
test("system known_hosts paths include the OpenSSH defaults for the platform", () => {
|
||||
const { api } = makeApi();
|
||||
const paths = api.getSystemKnownHostsPaths();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { jsonSchema, tool } from 'ai';
|
||||
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||
import { processCattyStream, shouldEmitAgentEventsForStreamChunk } from './turnDrivers/cattyStreamProcessor';
|
||||
import type { CattyToolsBundle } from './capabilityTools';
|
||||
import { createInitialCattyRuntimeContext } from './cattyRuntimeContext';
|
||||
import type { ChatMessage } from '../types';
|
||||
|
||||
@@ -113,100 +111,3 @@ describe('processCattyStream reasoning continuation', () => {
|
||||
assert.match(continuation?.reasoningParts?.map(part => part.text).join('') ?? '', /thinking/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processCattyStream iteration limit', () => {
|
||||
const usage = {
|
||||
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||
};
|
||||
|
||||
async function run(t: import('node:test').TestContext, replies: ('poll' | 'summary')[], maxIterations: number) {
|
||||
const originalRaf = globalThis.requestAnimationFrame;
|
||||
const originalCancelRaf = globalThis.cancelAnimationFrame;
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = originalRaf;
|
||||
globalThis.cancelAnimationFrame = originalCancelRaf;
|
||||
});
|
||||
globalThis.requestAnimationFrame = callback => setTimeout(() => callback(0), 0) as unknown as number;
|
||||
globalThis.cancelAnimationFrame = id => clearTimeout(id);
|
||||
|
||||
const messages: ChatMessage[] = [{ id: 'assistant-1', role: 'assistant', content: '', timestamp: 0 }];
|
||||
let calls = 0;
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async () => {
|
||||
const reply = replies[calls++];
|
||||
assert.ok(reply, 'unexpected extra model step');
|
||||
return {
|
||||
stream: simulateReadableStream({ chunks: [
|
||||
{ type: 'stream-start', warnings: [] },
|
||||
...(reply === 'poll' ? [{
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: `poll-${calls}`,
|
||||
toolName: 'terminal_poll',
|
||||
input: '{"jobId":"job-1"}',
|
||||
}] : [
|
||||
{ type: 'text-start' as const, id: 'summary' },
|
||||
{ type: 'text-delta' as const, id: 'summary', delta: 'The job completed.' },
|
||||
{ type: 'text-end' as const, id: 'summary' },
|
||||
]),
|
||||
{ type: 'finish', finishReason: { unified: reply === 'poll' ? 'tool-calls' : 'stop', raw: undefined }, usage },
|
||||
] }),
|
||||
};
|
||||
},
|
||||
});
|
||||
await processCattyStream({
|
||||
streamSessionId: 'session-1', model, systemPrompt: 'test',
|
||||
toolsBundle: {
|
||||
tools: {
|
||||
terminal_poll: tool({
|
||||
inputSchema: jsonSchema<{ jobId: string }>({
|
||||
type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'],
|
||||
}),
|
||||
execute: async ({ jobId }) => ({ jobId, status: 'running', output: 'buffered output' }),
|
||||
}),
|
||||
} as unknown as CattyToolsBundle['tools'],
|
||||
toolsContext: {},
|
||||
},
|
||||
sdkMessages: [{ role: 'user', content: 'Check the job' }],
|
||||
signal: new AbortController().signal,
|
||||
currentAssistantMsgId: 'assistant-1', maxIterations,
|
||||
runtimeContext: createInitialCattyRuntimeContext({
|
||||
chatSessionId: 'session-1', turnId: 'turn-1', permissionMode: 'auto', scopeType: 'terminal',
|
||||
}),
|
||||
ui: {
|
||||
addMessageToSession: (sessionId, message) => {
|
||||
assert.equal(sessionId, 'session-1');
|
||||
messages.push(message);
|
||||
},
|
||||
updateMessageById: (_sessionId, id, updater) => {
|
||||
const index = messages.findIndex(message => message.id === id);
|
||||
if (index >= 0) messages[index] = updater(messages[index]);
|
||||
},
|
||||
},
|
||||
});
|
||||
return { messages, calls };
|
||||
}
|
||||
|
||||
it('adds a non-error assistant notice after terminal_poll exhausts the step cap', async t => {
|
||||
const { messages, calls } = await run(t, ['poll', 'poll'], 2);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(messages.at(-2)?.role, 'tool');
|
||||
assert.match(messages.at(-2)?.toolResults?.[0].content ?? '', /job-1/);
|
||||
const notice = messages.at(-1)!;
|
||||
assert.equal(notice.role, 'assistant');
|
||||
assert.equal(notice.iterationLimit, 2);
|
||||
assert.equal(notice.errorInfo, undefined);
|
||||
});
|
||||
|
||||
for (const [name, replies, maxIterations] of [
|
||||
['natural stop before the cap', ['summary'], 2],
|
||||
['natural stop on the last allowed step', ['summary'], 1],
|
||||
['post-tool summary on the last allowed step', ['poll', 'summary'], 2],
|
||||
] as const) {
|
||||
it(`does not add a limit notice for ${name}`, async t => {
|
||||
const { messages } = await run(t, [...replies], maxIterations);
|
||||
assert.equal(messages.at(-1)?.content, 'The job completed.');
|
||||
assert.ok(messages.every(message => message.iterationLimit === undefined));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -141,8 +141,6 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
: undefined;
|
||||
const promptCache = createCattyPromptCache(systemPrompt, model.provider, endpoint);
|
||||
|
||||
let reachedIterationLimit = false;
|
||||
const stopAtIterationLimit = isStepCount(maxIterations);
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: promptCache.prepareMessages(sdkMessages),
|
||||
@@ -154,12 +152,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
permissionMode: runtimeContext.permissionMode,
|
||||
chatSessionId: runtimeContext.chatSessionId,
|
||||
}),
|
||||
// The SDK evaluates stopWhen only when tool work would allow another
|
||||
// step. A natural final reply (even on the last allowed step) bypasses it.
|
||||
stopWhen: async (options) => {
|
||||
reachedIterationLimit = await stopAtIterationLimit(options);
|
||||
return reachedIterationLimit;
|
||||
},
|
||||
stopWhen: isStepCount(maxIterations),
|
||||
abortSignal: signal,
|
||||
include: { rawChunks: true },
|
||||
timeout: buildCattyStreamTimeouts({
|
||||
@@ -587,16 +580,6 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
const usage = await result.usage;
|
||||
const finalStep = await result.finalStep;
|
||||
const performance = finalStep?.performance;
|
||||
if (reachedIterationLimit && !signal.aborted) {
|
||||
clearCompactionStatusFromAssistant(activeMsgId);
|
||||
ui.addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
iterationLimit: maxIterations,
|
||||
});
|
||||
}
|
||||
// onEnd can run before this consumer flushes its final text and creates the
|
||||
// post-tool assistant message. Attach turn totals only after that flush.
|
||||
if (usage) {
|
||||
|
||||
@@ -171,8 +171,6 @@ export interface ChatMessage {
|
||||
timestamp: number;
|
||||
model?: string;
|
||||
providerId?: AIProviderId;
|
||||
/** Catty stopped at its tool-loop cap; rendered as a non-error notice. */
|
||||
iterationLimit?: number;
|
||||
errorInfo?: {
|
||||
type: 'network' | 'auth' | 'timeout' | 'provider' | 'agent' | 'unknown';
|
||||
message: string;
|
||||
|
||||
+1
-2
@@ -111,7 +111,7 @@ export default defineConfig(() => {
|
||||
build: {
|
||||
// The shared runtime is also required by Electron; convert it for browser builds.
|
||||
commonjsOptions: {
|
||||
include: [/node_modules/, /domain\/sshPublicKey\.shared\.cjs$/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
|
||||
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
|
||||
},
|
||||
chunkSizeWarningLimit: 3000,
|
||||
target: 'esnext', // Required for top-level await in WASM modules
|
||||
@@ -134,7 +134,6 @@ export default defineConfig(() => {
|
||||
include: [
|
||||
// Keep the renderer alias pre-bundled so dev serves ESM instead of raw CommonJS.
|
||||
'@/domain/systemManager/containerRuntime.shared.cjs',
|
||||
'@/domain/sshPublicKey.shared.cjs',
|
||||
'@/domain/pluginRuntimePolicy.cjs',
|
||||
'@/domain/pluginGitHubRepository.cjs',
|
||||
'@/domain/pluginPermissionDescriptions.cjs',
|
||||
|
||||
Reference in new issue
Block a user