mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-18 18:29:35 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6a46621dd |
@@ -89,22 +89,6 @@ pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
|||||||
./container/build.sh
|
./container/build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restart the host
|
|
||||||
|
|
||||||
The image rebuild does not reload the **host**. Codex's host contribution
|
|
||||||
(`src/providers/codex.ts`) registers the `/home/node/.codex` bind mount + env
|
|
||||||
passthrough, and the running host only picks it up on restart. Skip this and the
|
|
||||||
first Codex turn fails with `EACCES` writing `/home/node/.codex/config.toml` —
|
|
||||||
with no mount, Docker auto-creates the dir root-owned and the non-root container
|
|
||||||
user can't write to it.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# macOS (launchd)
|
|
||||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
|
||||||
# Linux (systemd)
|
|
||||||
systemctl --user restart nanoclaw
|
|
||||||
```
|
|
||||||
|
|
||||||
### Validate
|
### Validate
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -116,8 +100,6 @@ The registration tests import only the real barrels — they go red if a barrel
|
|||||||
|
|
||||||
## Authenticate
|
## Authenticate
|
||||||
|
|
||||||
> **Run this in a separate, real terminal — it is interactive.** It prompts for ChatGPT-subscription vs OpenAI-API-key and then drives a browser/device login, so it needs a TTY to answer prompts.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm exec tsx setup/index.ts --step provider-auth codex
|
pnpm exec tsx setup/index.ts --step provider-auth codex
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getCurrentInReplyTo } from '../current-batch.js';
|
|||||||
import { findByName, getAllDestinations } from '../destinations.js';
|
import { findByName, getAllDestinations } from '../destinations.js';
|
||||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
||||||
import { getSessionRouting } from '../db/session-routing.js';
|
import { getSessionRouting } from '../db/session-routing.js';
|
||||||
|
import { enqueueFileOut } from '../outbox.js';
|
||||||
import { registerTools } from './server.js';
|
import { registerTools } from './server.js';
|
||||||
import type { McpToolDefinition } from './types.js';
|
import type { McpToolDefinition } from './types.js';
|
||||||
|
|
||||||
@@ -156,21 +157,16 @@ export const sendFile: McpToolDefinition = {
|
|||||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
|
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
|
||||||
if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`);
|
if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`);
|
||||||
|
|
||||||
const id = generateId();
|
const { id, filename } = enqueueFileOut({
|
||||||
const filename = (args.filename as string) || path.basename(resolvedPath);
|
srcPath: resolvedPath,
|
||||||
|
routing: {
|
||||||
const outboxDir = path.join('/workspace/outbox', id);
|
platform_id: routing.platform_id,
|
||||||
fs.mkdirSync(outboxDir, { recursive: true });
|
channel_type: routing.channel_type,
|
||||||
fs.copyFileSync(resolvedPath, path.join(outboxDir, filename));
|
thread_id: routing.thread_id,
|
||||||
|
in_reply_to: getCurrentInReplyTo(),
|
||||||
writeMessageOut({
|
},
|
||||||
id,
|
text: (args.text as string) || '',
|
||||||
in_reply_to: getCurrentInReplyTo(),
|
filename: (args.filename as string) || undefined,
|
||||||
kind: 'chat',
|
|
||||||
platform_id: routing.platform_id,
|
|
||||||
channel_type: routing.channel_type,
|
|
||||||
thread_id: routing.thread_id,
|
|
||||||
content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
log(`send_file: ${id} → ${routing.resolvedName} (${filename})`);
|
log(`send_file: ${id} → ${routing.resolvedName} (${filename})`);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { initTestSessionDb, closeSessionDb } from './db/connection.js';
|
||||||
|
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||||
|
import { enqueueFileOut } from './outbox.js';
|
||||||
|
|
||||||
|
let outboxDir: string;
|
||||||
|
let srcDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initTestSessionDb();
|
||||||
|
outboxDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-outbox-'));
|
||||||
|
srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-src-'));
|
||||||
|
process.env.NANOCLAW_OUTBOX_DIR = outboxDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeSessionDb();
|
||||||
|
delete process.env.NANOCLAW_OUTBOX_DIR;
|
||||||
|
fs.rmSync(outboxDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(srcDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeSrc(name: string, bytes: string): string {
|
||||||
|
const p = path.join(srcDir, name);
|
||||||
|
fs.writeFileSync(p, bytes);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('enqueueFileOut', () => {
|
||||||
|
it('stages the file under the outbox and enqueues a messages_out row with files[]', () => {
|
||||||
|
const src = writeSrc('ig_abc.png', 'PNGDATA');
|
||||||
|
|
||||||
|
const { id, filename } = enqueueFileOut({
|
||||||
|
srcPath: src,
|
||||||
|
routing: { platform_id: 'chan-1', channel_type: 'discord', thread_id: 'thr-9', in_reply_to: 'm1' },
|
||||||
|
text: 'here you go',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bytes staged at <outbox>/<id>/<filename> for the host to read.
|
||||||
|
const staged = path.join(outboxDir, id, filename);
|
||||||
|
expect(fs.existsSync(staged)).toBe(true);
|
||||||
|
expect(fs.readFileSync(staged, 'utf8')).toBe('PNGDATA');
|
||||||
|
|
||||||
|
// Exactly one outbound row, carrying the file reference + routing.
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
const row = out[0];
|
||||||
|
expect(row.platform_id).toBe('chan-1');
|
||||||
|
expect(row.channel_type).toBe('discord');
|
||||||
|
expect(row.thread_id).toBe('thr-9');
|
||||||
|
expect(row.in_reply_to).toBe('m1');
|
||||||
|
const content = JSON.parse(row.content);
|
||||||
|
expect(content.files).toEqual(['ig_abc.png']);
|
||||||
|
expect(content.text).toBe('here you go');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults filename to the basename and text to empty', () => {
|
||||||
|
const src = writeSrc('chart.png', 'X');
|
||||||
|
|
||||||
|
const { filename } = enqueueFileOut({
|
||||||
|
srcPath: src,
|
||||||
|
routing: { platform_id: 'C-1', channel_type: 'slack', thread_id: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filename).toBe('chart.png');
|
||||||
|
const row = getUndeliveredMessages()[0];
|
||||||
|
expect(row.in_reply_to).toBeNull();
|
||||||
|
const content = JSON.parse(row.content);
|
||||||
|
expect(content.text).toBe('');
|
||||||
|
expect(content.files).toEqual(['chart.png']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the source file is missing — callers decide how to surface it', () => {
|
||||||
|
expect(() =>
|
||||||
|
enqueueFileOut({
|
||||||
|
srcPath: path.join(srcDir, 'does-not-exist.png'),
|
||||||
|
routing: { platform_id: 'C-1', channel_type: 'slack', thread_id: null },
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
// Nothing enqueued on failure.
|
||||||
|
expect(getUndeliveredMessages()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* File delivery via the outbox.
|
||||||
|
*
|
||||||
|
* A file is delivered in two parts that must stay in lockstep: the bytes are
|
||||||
|
* staged under `/workspace/outbox/<id>/<filename>` (the host reads them from
|
||||||
|
* there after polling), and a `messages_out` row carries `{ files: [name] }`
|
||||||
|
* so the host knows to attach them. This helper owns that contract so the two
|
||||||
|
* callers — the `send_file` MCP tool (model-driven) and the poll-loop's `file`
|
||||||
|
* event consumer (harness-generated images) — can't drift apart.
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { writeMessageOut } from './db/messages-out.js';
|
||||||
|
|
||||||
|
/** Where staged files live. Overridable for tests; production is always the mount. */
|
||||||
|
function outboxBase(): string {
|
||||||
|
return process.env.NANOCLAW_OUTBOX_DIR ?? '/workspace/outbox';
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileOutRouting {
|
||||||
|
platform_id: string;
|
||||||
|
channel_type: string;
|
||||||
|
thread_id: string | null;
|
||||||
|
in_reply_to?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnqueueFileOut {
|
||||||
|
/** Absolute or already-resolved path to the file to deliver. Must exist. */
|
||||||
|
srcPath: string;
|
||||||
|
routing: FileOutRouting;
|
||||||
|
/** Optional accompanying message text. */
|
||||||
|
text?: string;
|
||||||
|
/** Display name; defaults to the basename of `srcPath`. */
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage a file into the outbox and enqueue its `messages_out` row.
|
||||||
|
*
|
||||||
|
* Throws if `srcPath` cannot be read/copied — callers decide whether that
|
||||||
|
* should surface to the user (the MCP tool validates existence first; the
|
||||||
|
* poll-loop consumer logs and moves on so one bad image can't fail the turn).
|
||||||
|
*/
|
||||||
|
export function enqueueFileOut(opts: EnqueueFileOut): { id: string; filename: string; seq: number } {
|
||||||
|
const id = generateId();
|
||||||
|
const filename = opts.filename ?? path.basename(opts.srcPath);
|
||||||
|
|
||||||
|
const outboxDir = path.join(outboxBase(), id);
|
||||||
|
fs.mkdirSync(outboxDir, { recursive: true });
|
||||||
|
fs.copyFileSync(opts.srcPath, path.join(outboxDir, filename));
|
||||||
|
|
||||||
|
const seq = writeMessageOut({
|
||||||
|
id,
|
||||||
|
in_reply_to: opts.routing.in_reply_to ?? null,
|
||||||
|
kind: 'chat',
|
||||||
|
platform_id: opts.routing.platform_id,
|
||||||
|
channel_type: opts.routing.channel_type,
|
||||||
|
thread_id: opts.routing.thread_id,
|
||||||
|
content: JSON.stringify({ text: opts.text ?? '', files: [filename] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id, filename, seq };
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type RoutingContext,
|
type RoutingContext,
|
||||||
} from './formatter.js';
|
} from './formatter.js';
|
||||||
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
|
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
|
||||||
|
import { enqueueFileOut } from './outbox.js';
|
||||||
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderExchange } from './providers/types.js';
|
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderExchange } from './providers/types.js';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 1000;
|
const POLL_INTERVAL_MS = 1000;
|
||||||
@@ -507,6 +508,8 @@ async function processQuery(
|
|||||||
} else {
|
} else {
|
||||||
archivePrompts.shift();
|
archivePrompts.shift();
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'file') {
|
||||||
|
deliverHarnessFile(event.path, routing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -557,6 +560,34 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver a harness-generated file (e.g. a Codex-rendered image) to the
|
||||||
|
* batch's reply destination. The model never sends these itself — its native
|
||||||
|
* client already rendered them — so the loop delivers them via the same outbox
|
||||||
|
* path send_file uses. Best-effort: a missing reply destination or an
|
||||||
|
* unreadable file logs and is skipped rather than failing the whole turn.
|
||||||
|
*/
|
||||||
|
function deliverHarnessFile(filePath: string, routing: RoutingContext): void {
|
||||||
|
if (!routing.platformId || !routing.channelType) {
|
||||||
|
log(`Dropping harness file ${filePath}: batch has no reply destination`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { filename, seq } = enqueueFileOut({
|
||||||
|
srcPath: filePath,
|
||||||
|
routing: {
|
||||||
|
platform_id: routing.platformId,
|
||||||
|
channel_type: routing.channelType,
|
||||||
|
thread_id: routing.threadId,
|
||||||
|
in_reply_to: routing.inReplyTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log(`Delivered harness file #${seq} → ${routing.channelType}:${routing.platformId} (${filename})`);
|
||||||
|
} catch (err) {
|
||||||
|
log(`Failed to deliver harness file ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the agent's final text for <message to="name">...</message> blocks
|
* Parse the agent's final text for <message to="name">...</message> blocks
|
||||||
* and dispatch each one to its resolved destination. Text outside of blocks
|
* and dispatch each one to its resolved destination. Text outside of blocks
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ export type ProviderEvent =
|
|||||||
| { type: 'result'; text: string | null }
|
| { type: 'result'; text: string | null }
|
||||||
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
| { type: 'error'; message: string; retryable: boolean; classification?: string }
|
||||||
| { type: 'progress'; message: string }
|
| { type: 'progress'; message: string }
|
||||||
|
/**
|
||||||
|
* A file the harness produced that the model won't deliver itself (e.g.
|
||||||
|
* Codex's built-in image generation renders to its native client, so the
|
||||||
|
* model believes delivery already happened). The poll-loop delivers it to
|
||||||
|
* the batch's reply destination. `path` is absolute inside the container.
|
||||||
|
*/
|
||||||
|
| { type: 'file'; path: string }
|
||||||
/**
|
/**
|
||||||
* Liveness signal. Providers MUST yield this on every underlying SDK
|
* Liveness signal. Providers MUST yield this on every underlying SDK
|
||||||
* event (tool call, thinking, partial message, anything) so the
|
* event (tool call, thinking, partial message, anything) so the
|
||||||
|
|||||||
Reference in New Issue
Block a user