Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/dmoss-agent/src/cli/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,6 @@ export function preparePromptAttachments(
const imageMime = IMAGE_MIME_BY_EXT[ext];
const filename = path.basename(absPath);
const label = relativeLabel(absPath, cwd);
const index = nextIndex;

if (imageMime) {
if (stat.size === 0) {
Expand All @@ -235,6 +234,7 @@ export function preparePromptAttachments(
continue;
}
const data = buffer.toString('base64');
const index = nextIndex;
attachments.push({ index, kind: 'image', path: absPath, label, filename, mimeType: detectedMime, bytes: stat.size });
blocks.push({ type: 'text', text: attachmentTextHeader('Image', index, label) });
blocks.push({ type: 'image', data, mimeType: detectedMime, filename });
Expand All @@ -251,6 +251,7 @@ export function preparePromptAttachments(
continue;
}
const text = fs.readFileSync(absPath, 'utf8');
const index = nextIndex;
attachments.push({ index, kind: 'file', path: absPath, label, filename, mimeType: 'text/plain', bytes: stat.size });
blocks.push({
type: 'text',
Expand Down
5 changes: 3 additions & 2 deletions packages/dmoss-agent/src/cli/community-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ export function resolveCommunityAuthSessionPath(configDir = resolveConfigDir()):
return path.join(configDir, 'community-auth.json');
}

function normalizePortalToken(raw: unknown): string {
/** @internal exported for tests — normalizes a pasted portal token. */
export function normalizePortalToken(raw: unknown): string {
if (typeof raw !== 'string') return '';
let token = raw.trim();
if (!token) return '';
Expand All @@ -103,7 +104,7 @@ function normalizePortalToken(raw: unknown): string {
}
}
token = token.replace(/^Bearer\s+/i, '').trim();
token = token.replace(/\s+/g, '+');
token = token.replace(/\s+/g, '');
return token.length >= 8 ? token : '';
}

Expand Down
4 changes: 3 additions & 1 deletion packages/dmoss-agent/src/cli/input/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function handleVimKey(key: string, cursorPos: number, lineLength: number)
}

// ── Digit accumulation for count prefix (in normal mode) ──
if (vimState.mode === 'normal' && /^[1-9]$/.test(key)) {
if (vimState.mode === 'normal' && /^[0-9]$/.test(key)) {
// Allow "0" only if we already have a prefix
if (key === '0' && vimState.countPrefix === '') return { type: 'none' };
vimState.countPrefix += key;
Expand Down Expand Up @@ -252,6 +252,7 @@ export function getVimModeIndicator(): string {
case 'normal': return 'NORMAL';
case 'insert': return 'INSERT';
case 'visual': return 'VISUAL';
default: return 'INSERT';
}
}

Expand All @@ -261,5 +262,6 @@ export function getVimModeColor(): string {
case 'normal': return '#38bdf8'; // blue
case 'insert': return '#22c55e'; // green
case 'visual': return '#a78bfa'; // purple
default: return '#22c55e';
}
}
18 changes: 10 additions & 8 deletions packages/dmoss-agent/src/cli/print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,21 +275,23 @@ export function isHeadlessResultError(event: HeadlessResultEvent): boolean {
return event.is_error;
}

function safeJson(value: unknown): string {
function safeJson(value: unknown, state?: HeadlessPrintState): string {
try {
return JSON.stringify(value);
} catch {
return JSON.stringify({
// Emit a result event with actual state values, not placeholders
const fallbackResult = {
type: 'result',
subtype: 'error_during_execution',
is_error: true,
result: '',
duration_ms: 0,
num_turns: 0,
session_id: '',
result: state?.finalText ?? '',
duration_ms: state ? Math.max(0, Date.now() - state.startTime) : 0,
num_turns: state?.numTurns ?? 0,
session_id: state?.sessionId ?? 'unknown',
total_cost_usd: 0,
error: 'unserializable output',
});
error: 'failed to serialize event to JSON',
};
return JSON.stringify(fallbackResult);
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/dmoss-agent/src/cli/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3638,7 +3638,7 @@ export function DmossTui({ agent, skillLearner, runtime, sessionKey: initialSess
} : picker);
return;
}
if (/^\d$/.test(inputChar)) {
if (/^[1-9]$/.test(inputChar)) {
const selected = Number.parseInt(inputChar, 10) - 1;
const choice = choices[selected];
if (choice) {
Expand Down
23 changes: 22 additions & 1 deletion packages/dmoss-agent/src/mcp/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,21 @@ class McpServerConnection {
}
this.pending.clear();
});

// A broken stdin pipe (the child closed its read end / already exited) is
// surfaced asynchronously as an 'error' event on the writable stream. With
// no listener Node escalates it to an uncaught exception that kills the
// whole process — observed as `write EPIPE` on Linux, while macOS silently
// discards the write. Absorb it and fail any in-flight requests; once stdin
// breaks the connection can no longer send, so it is effectively closed.
this.process.stdin!.on('error', (err) => {
this.closed = true;
for (const [, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(new DmossError({ code: ErrorCode.MCP_CONNECTION_FAILED, message: `MCP server ${serverName} stdin error: ${err instanceof Error ? err.message : String(err)}` }));
}
this.pending.clear();
});
}

private processBuffer(): void {
Expand Down Expand Up @@ -355,7 +370,13 @@ class McpServerConnection {
notify(method: string, params?: unknown): void {
if (this.closed) return;
const msg: JsonRpcNotification = { jsonrpc: '2.0', method, params };
this.process.stdin!.write(JSON.stringify(msg) + '\n');
try {
this.process.stdin!.write(JSON.stringify(msg) + '\n');
} catch {
// Best-effort notification: writing to an already-destroyed stdin throws
// synchronously (ERR_STREAM_DESTROYED). Async pipe errors are handled by
// the stdin 'error' listener; nothing here needs the failure to surface.
}
}

async initialize(): Promise<void> {
Expand Down
7 changes: 6 additions & 1 deletion packages/dmoss-agent/src/provider/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,12 @@ export class AnthropicLLMProvider implements LLMProvider {
const block = content[currentToolBlock];
if (block?.type === 'tool_use' && toolInputJson) {
try {
(block as { type: 'tool_use'; input: Record<string, unknown> }).input = JSON.parse(toolInputJson);
const parsed = JSON.parse(toolInputJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
(block as { type: 'tool_use'; input: Record<string, unknown> }).input = parsed as Record<string, unknown>;
} else {
throw new Error(`Expected object, got ${typeof parsed === 'object' && Array.isArray(parsed) ? 'array' : typeof parsed}`);
}
} catch (err) {
throw new DmossError({
code: ErrorCode.PROVIDER_UPSTREAM_ERROR,
Expand Down
8 changes: 7 additions & 1 deletion packages/dmoss-agent/src/tools/browser-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,13 @@ async function withBrowser<T>(
return `${toolName} 未执行: ${err instanceof Error ? err.message : String(err)}`;
} finally {
try {
await browser?.close();
if (browser) {
const closePromise = browser.close();
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(resolve, 2_000);
});
await Promise.race([closePromise, timeoutPromise]);
}
} catch {
/* best effort */
}
Expand Down
3 changes: 2 additions & 1 deletion packages/dmoss-agent/src/tools/web-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,8 @@ export function createWebFetchTool(opts: WebFetchOptions = {}): Tool<{ url: stri
});
}
redirectCount++;
if (blockPrivate) {
const redirectPrivateWaived = blockPrivate && resolveAllowPrivate().some((p) => hostMatches(nextUrl.hostname, p));
if (blockPrivate && !redirectPrivateWaived) {
verifiedIp = await resolveHostIp(nextUrl.hostname, resolveAddresses);
if (verifiedIp === null) {
res.body?.cancel?.();
Expand Down
32 changes: 32 additions & 0 deletions packages/dmoss-agent/test/community-auth-token-normalize.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* normalizePortalToken must STRIP internal whitespace from a pasted token, not
* replace it with '+'. A line-wrapped paste (header.\npayload.\nsignature) was
* being turned into header.+payload.+signature — an invalid JWT.
*
* Run:
* npm run build -w @rdk-moss/agent
* node packages/dmoss-agent/test/community-auth-token-normalize.spec.mjs
*/
import assert from 'node:assert/strict';
import { normalizePortalToken } from '../dist/cli/community-auth.js';

// A line-wrapped token must rejoin WITHOUT inserting '+' characters.
assert.equal(
normalizePortalToken('aaaaaaaa.\nbbbbbbbb.\ncccccccc'),
'aaaaaaaa.bbbbbbbb.cccccccc',
'line breaks inside a token must be removed, not turned into "+"',
);
assert.doesNotMatch(normalizePortalToken('aaaaaaaa.\nbbbbbbbb'), /\+/, 'no "+" may be introduced');

// Bearer prefix + surrounding whitespace is stripped.
assert.equal(normalizePortalToken(' Bearer abcdefghij '), 'abcdefghij');

// Tabs/spaces between segments are removed too.
assert.equal(normalizePortalToken('abcd\t efgh \r\n ijkl'), 'abcdefghijkl');

// Too-short tokens normalize to empty.
assert.equal(normalizePortalToken('abc'), '');
assert.equal(normalizePortalToken(undefined), '');

console.log('[PASS] normalizePortalToken strips whitespace instead of inserting "+"');
7 changes: 4 additions & 3 deletions packages/dmoss-agent/test/run-process-abort-cleanup.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { ProcessError } from '../dist/utils/run-process.js';
// Test that aborting a process works correctly and cleanup is idempotent.
const controller = new AbortController();

// Start a process that will sleep
const promise = runProcess('/bin/sh', {
args: ['-c', 'sleep 10'],
// Start a long-running child via the Node binary (present on every platform —
// '/bin/sh' does not exist on Windows and would ENOENT before the abort fires).
const promise = runProcess(process.execPath, {
args: ['-e', 'setTimeout(() => {}, 10000)'],
signal: controller.signal,
});

Expand Down
28 changes: 28 additions & 0 deletions packages/dmoss-agent/test/vim-count-digit-zero.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env node
/**
* Vim normal-mode count prefix must accept '0' as a non-leading digit, so a
* count like 10 works. The digit-accumulation guard used /^[1-9]$/, which made
* '0' fall through to the line-start motion even mid-count.
*
* Run:
* npm run build -w @rdk-moss/agent
* node packages/dmoss-agent/test/vim-count-digit-zero.spec.mjs
*/
import assert from 'node:assert/strict';

// handleVimKey is gated on isVimEnabled() (DMOSS_VIM_MODE=1); enable before import-side use.
process.env.DMOSS_VIM_MODE = '1';
const { handleVimKey, getVimState, setVimMode } = await import('../dist/cli/input/vim.js');

setVimMode('normal');
// A bare '0' is the line-start motion, not the start of a count.
handleVimKey('0', 5, 10);
assert.equal(getVimState().countPrefix, '', "'0' with no prefix is a motion, not a count");

// '0' after a non-zero digit must accumulate into a multi-digit count (10).
handleVimKey('1', 5, 10);
assert.equal(getVimState().countPrefix, '1', "'1' starts a count");
handleVimKey('0', 5, 10);
assert.equal(getVimState().countPrefix, '10', "'0' must accumulate into the count to form 10");

console.log('[PASS] vim count prefix accepts 0 as a non-leading digit');
3 changes: 2 additions & 1 deletion packages/dmoss-memory/src/memory-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,8 @@ export class MemoryManager {
const tokens = content.toLowerCase().match(/[a-z0-9一-鿿]+/g) ?? [];
const terms = new Set<string>();
for (const token of tokens) {
for (let len = 2; len <= token.length; len++) {
const maxNgramLen = Math.min(6, token.length);
for (let len = 2; len <= maxNgramLen; len++) {
for (let start = 0; start <= token.length - len; start++) {
terms.add(token.slice(start, start + len));
}
Expand Down
19 changes: 19 additions & 0 deletions packages/dmoss-memory/test/memory-manager-ngrams.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test } from 'node:test';
import assert from 'node:assert';
import { MemoryManager } from '../dist/memory-manager.js';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rmSync } from 'node:fs';

test('extractTerms limits n-gram generation on long tokens', async () => {
const testDir = join(tmpdir(), `memory-ngram-${Date.now()}`);
const manager = new MemoryManager(testDir);

const longToken = 'a'.repeat(1000);
const terms = manager.extractTerms(longToken);

assert(terms.length < 10000,
`Terms should be capped, got ${terms.length}`);

rmSync(testDir, { recursive: true, force: true });
});
2 changes: 0 additions & 2 deletions packages/dmoss-skills/src/skill-learner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,6 @@ export class SkillLearner {
const idx = callIdToIndex.get(useId);
if (idx !== undefined) {
calls[idx].failed = true;
} else if (calls.length > 0) {
calls[calls.length - 1].failed = true;
}
}
}
Expand Down
38 changes: 38 additions & 0 deletions packages/dmoss-skills/test/skill-learner-error-tracking.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { test } from 'node:test';
import assert from 'node:assert';
import { SkillLearner } from '../dist/skill-learner.js';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rmSync } from 'node:fs';

test('extractToolCalls does not falsely attribute unmatched errors to last call', async () => {
const testDir = join(tmpdir(), `skills-${Date.now()}`);
const learner = new SkillLearner({ skillsDir: testDir });

const messages = [
{
role: 'assistant',
content: [
{ type: 'tool_use', id: 'call_1', name: 'exec', input: {} },
{ type: 'tool_use', id: 'call_2', name: 'read', input: {} },
{ type: 'tool_use', id: 'call_3', name: 'write', input: {} },
]
},
{
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'call_1', is_error: false },
{ type: 'tool_result', tool_use_id: 'unknown_id_1', is_error: true },
{ type: 'tool_result', tool_use_id: 'call_3', is_error: false },
]
}
];

const toolCalls = learner.extractToolCalls(messages);

assert.strictEqual(toolCalls[0].failed, false, 'exec succeeded');
assert.strictEqual(toolCalls[1].failed, false, 'read should not be marked failed');
assert.strictEqual(toolCalls[2].failed, false, 'write succeeded');

rmSync(testDir, { recursive: true, force: true });
});
2 changes: 1 addition & 1 deletion packages/dmoss/src/contracts/async-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export class InMemoryMossAsyncTaskRegistry implements MossAsyncTaskRegistry {
this.complete(record, {
status: 'failed',
success: false,
summary: result.summary,
summary: result.summary || 'task failed',
error: result.summary || 'task failed',
data: result.data,
});
Expand Down
13 changes: 7 additions & 6 deletions packages/dmoss/src/contracts/host-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,12 +574,13 @@ function validateMossCapabilityCoverageShape(
return `manifest.capabilityCoverage[].tools references unknown tool: ${entry.id} -> ${toolName}`;
}
const toolSurface = tool.surface;
if (
entrySurfaces.size > 0 &&
isOneOf(toolSurface, MOSS_HOST_TOOL_SURFACE_KINDS) &&
!entrySurfaces.has(toolSurface)
) {
return `manifest.capabilityCoverage[].tools references tool from different surface: ${entry.id} -> ${toolName} (${tool.surface})`;
if (entrySurfaces.size > 0) {
if (!isOneOf(toolSurface, MOSS_HOST_TOOL_SURFACE_KINDS)) {
return `manifest.capabilityCoverage[].tools references tool without declared surface: ${entry.id} -> ${toolName}`;
}
if (!entrySurfaces.has(toolSurface)) {
return `manifest.capabilityCoverage[].tools references tool from different surface: ${entry.id} -> ${toolName} (${tool.surface})`;
}
}
}
}
Expand Down
Loading
Loading